From 60d61b9e319758713de18cee0a651cbb6ecd1db8 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Sun, 10 Nov 2024 21:23:55 -0700 Subject: [PATCH 001/119] feat: Added initial Sonarr CLI support and the initial network handler setup for the TUI --- src/app/app_tests.rs | 18 +- src/app/mod.rs | 28 +- src/cli/cli_tests.rs | 48 +- src/cli/mod.rs | 22 +- src/cli/radarr/get_command_handler_tests.rs | 2 +- src/cli/radarr/list_command_handler_tests.rs | 2 +- src/cli/sonarr/get_command_handler.rs | 60 ++ src/cli/sonarr/get_command_handler_tests.rs | 71 ++ src/cli/sonarr/list_command_handler.rs | 60 ++ src/cli/sonarr/list_command_handler_tests.rs | 77 ++ src/cli/sonarr/mod.rs | 75 ++ src/cli/sonarr/sonarr_command_tests.rs | 86 ++ src/main.rs | 4 +- src/models/mod.rs | 16 +- src/models/model_tests.rs | 8 + src/models/servarr_data/mod.rs | 1 + .../servarr_data/radarr/radarr_data_tests.rs | 10 +- src/models/servarr_data/sonarr/mod.rs | 1 + src/models/servarr_data/sonarr/sonarr_data.rs | 43 + .../servarr_data/sonarr/sonarr_data_tests.rs | 42 + src/models/sonarr_models.rs | 207 +++++ src/models/sonarr_models_tests.rs | 92 ++ src/network/mod.rs | 82 +- src/network/network_tests.rs | 291 +++++- src/network/radarr_network.rs | 511 +++++------ src/network/radarr_network_tests.rs | 866 +++++++++--------- src/network/sonarr_network.rs | 106 +++ src/network/sonarr_network_tests.rs | 351 +++++++ 28 files changed, 2419 insertions(+), 761 deletions(-) create mode 100644 src/cli/sonarr/get_command_handler.rs create mode 100644 src/cli/sonarr/get_command_handler_tests.rs create mode 100644 src/cli/sonarr/list_command_handler.rs create mode 100644 src/cli/sonarr/list_command_handler_tests.rs create mode 100644 src/cli/sonarr/mod.rs create mode 100644 src/cli/sonarr/sonarr_command_tests.rs create mode 100644 src/models/servarr_data/sonarr/mod.rs create mode 100644 src/models/servarr_data/sonarr/sonarr_data.rs create mode 100644 src/models/servarr_data/sonarr/sonarr_data_tests.rs create mode 100644 src/models/sonarr_models.rs create mode 100644 src/models/sonarr_models_tests.rs create mode 100644 src/network/sonarr_network.rs create mode 100644 src/network/sonarr_network_tests.rs diff --git a/src/app/app_tests.rs b/src/app/app_tests.rs index 82af22a..102a064 100644 --- a/src/app/app_tests.rs +++ b/src/app/app_tests.rs @@ -5,9 +5,10 @@ mod tests { use tokio::sync::mpsc; use crate::app::context_clues::{build_context_clue_string, SERVARR_CONTEXT_CLUES}; - use crate::app::{App, Data, RadarrConfig, DEFAULT_ROUTE}; + use crate::app::{App, Data, ServarrConfig, DEFAULT_ROUTE}; use crate::models::servarr_data::radarr::radarr_data::{ActiveRadarrBlock, RadarrData}; - use crate::models::{HorizontallyScrollableText, Route, TabRoute}; + use crate::models::servarr_data::sonarr::sonarr_data::{ActiveSonarrBlock, SonarrData}; + use crate::models::{HorizontallyScrollableText, TabRoute}; use crate::network::radarr_network::RadarrEvent; use crate::network::NetworkEvent; @@ -34,7 +35,7 @@ mod tests { }, TabRoute { title: "Sonarr", - route: Route::Sonarr, + route: ActiveSonarrBlock::Series.into(), help: format!("{} ", build_context_clue_string(&SERVARR_CONTEXT_CLUES)), contextual_help: None, }, @@ -126,6 +127,10 @@ mod tests { version: "test".to_owned(), ..RadarrData::default() }, + sonarr_data: SonarrData { + version: "test".to_owned(), + ..SonarrData::default() + }, }, ..App::default() }; @@ -135,6 +140,7 @@ mod tests { assert_eq!(app.tick_count, 0); assert_eq!(app.error, HorizontallyScrollableText::default()); assert!(app.data.radarr_data.version.is_empty()); + assert!(app.data.sonarr_data.version.is_empty()); } #[test] @@ -248,11 +254,11 @@ mod tests { } #[test] - fn test_radarr_config_default() { - let radarr_config = RadarrConfig::default(); + fn test_servarr_config_default() { + let radarr_config = ServarrConfig::default(); assert_eq!(radarr_config.host, Some("localhost".to_string())); - assert_eq!(radarr_config.port, Some(7878)); + assert_eq!(radarr_config.port, None); assert_eq!(radarr_config.uri, None); assert!(radarr_config.api_token.is_empty()); assert_eq!(radarr_config.ssl_cert_path, None); diff --git a/src/app/mod.rs b/src/app/mod.rs index 87faa8e..764280a 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -9,6 +9,7 @@ use tokio_util::sync::CancellationToken; use crate::app::context_clues::{build_context_clue_string, SERVARR_CONTEXT_CLUES}; use crate::models::servarr_data::radarr::radarr_data::{ActiveRadarrBlock, RadarrData}; +use crate::models::servarr_data::sonarr::sonarr_data::{ActiveSonarrBlock, SonarrData}; use crate::models::{HorizontallyScrollableText, Route, TabRoute, TabState}; use crate::network::NetworkEvent; @@ -151,7 +152,7 @@ impl<'a> Default for App<'a> { }, TabRoute { title: "Sonarr", - route: Route::Sonarr, + route: ActiveSonarrBlock::Series.into(), help: format!("{} ", build_context_clue_string(&SERVARR_CONTEXT_CLUES)), contextual_help: None, }, @@ -172,25 +173,24 @@ impl<'a> Default for App<'a> { #[derive(Default)] pub struct Data<'a> { pub radarr_data: RadarrData<'a>, -} - -pub trait ServarrConfig { - fn validate(&self); + pub sonarr_data: SonarrData, } #[derive(Debug, Deserialize, Serialize, Default)] pub struct AppConfig { - pub radarr: RadarrConfig, + pub radarr: ServarrConfig, + pub sonarr: ServarrConfig, } -impl ServarrConfig for AppConfig { - fn validate(&self) { +impl AppConfig { + pub fn validate(&self) { self.radarr.validate(); } } #[derive(Debug, Deserialize, Serialize)] -pub struct RadarrConfig { +#[cfg_attr(test, derive(Clone))] +pub struct ServarrConfig { pub host: Option, pub port: Option, pub uri: Option, @@ -198,20 +198,20 @@ pub struct RadarrConfig { pub ssl_cert_path: Option, } -impl ServarrConfig for RadarrConfig { +impl ServarrConfig { fn validate(&self) { if self.host.is_none() && self.uri.is_none() { - log_and_print_error("'host' or 'uri' is required for Radarr configuration".to_owned()); + log_and_print_error("'host' or 'uri' is required for configuration".to_owned()); process::exit(1); } } } -impl Default for RadarrConfig { +impl Default for ServarrConfig { fn default() -> Self { - RadarrConfig { + ServarrConfig { host: Some("localhost".to_string()), - port: Some(7878), + port: None, uri: None, api_token: "".to_string(), ssl_cert_path: None, diff --git a/src/cli/cli_tests.rs b/src/cli/cli_tests.rs index c080b00..d94e97c 100644 --- a/src/cli/cli_tests.rs +++ b/src/cli/cli_tests.rs @@ -20,9 +20,9 @@ mod tests { }; use pretty_assertions::assert_eq; - #[test] - fn test_radarr_subcommand_requires_subcommand() { - let result = Cli::command().try_get_matches_from(["managarr", "radarr"]); + #[rstest] + fn test_servarr_subcommand_requires_subcommand(#[values("radarr", "sonarr")] subcommand: &str) { + let result = Cli::command().try_get_matches_from(["managarr", subcommand]); assert!(result.is_err()); assert_eq!( @@ -39,6 +39,13 @@ mod tests { assert!(result.is_ok()); } + #[test] + fn test_sonarr_subcommand_delegates_to_sonarr() { + let result = Cli::command().try_get_matches_from(["managarr", "sonarr", "list", "series"]); + + assert!(result.is_ok()); + } + #[test] fn test_completions_requires_argument() { let result = Cli::command().try_get_matches_from(["managarr", "completions"]); @@ -121,10 +128,41 @@ mod tests { ))) }); let app_arc = Arc::new(Mutex::new(App::default())); - let claer_blocklist_command = RadarrCommand::ClearBlocklist.into(); + let clear_blocklist_command = RadarrCommand::ClearBlocklist.into(); - let result = handle_command(&app_arc, claer_blocklist_command, &mut mock_network).await; + let result = handle_command(&app_arc, clear_blocklist_command, &mut mock_network).await; 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(); + + // let result = handle_command(&app_arc, clear_blocklist_command, &mut mock_network).await; + + // assert!(result.is_ok()); + // } } diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 66c6bb2..1178276 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -4,11 +4,13 @@ use anyhow::Result; use clap::{command, Subcommand}; use clap_complete::Shell; use radarr::{RadarrCliHandler, RadarrCommand}; +use sonarr::{SonarrCliHandler, SonarrCommand}; use tokio::sync::Mutex; use crate::{app::App, network::NetworkTrait}; pub mod radarr; +pub mod sonarr; #[cfg(test)] #[path = "cli_tests.rs"] @@ -19,6 +21,9 @@ pub enum Command { #[command(subcommand, about = "Commands for manging your Radarr instance")] Radarr(RadarrCommand), + #[command(subcommand, about = "Commands for manging your Sonarr instance")] + Sonarr(SonarrCommand), + #[command( arg_required_else_help = true, about = "Generate shell completions for the Managarr CLI" @@ -45,11 +50,20 @@ pub(crate) async fn handle_command( command: Command, network: &mut dyn NetworkTrait, ) -> Result<()> { - if let Command::Radarr(radarr_command) = command { - RadarrCliHandler::with(app, radarr_command, network) - .handle() - .await? + match command { + Command::Radarr(radarr_command) => { + RadarrCliHandler::with(app, radarr_command, network) + .handle() + .await? + } + Command::Sonarr(sonarr_command) => { + SonarrCliHandler::with(app, sonarr_command, network) + .handle() + .await? + } + _ => (), } + Ok(()) } diff --git a/src/cli/radarr/get_command_handler_tests.rs b/src/cli/radarr/get_command_handler_tests.rs index 990e185..0bc629f 100644 --- a/src/cli/radarr/get_command_handler_tests.rs +++ b/src/cli/radarr/get_command_handler_tests.rs @@ -1,5 +1,5 @@ #[cfg(test)] -mod test { +mod tests { use clap::error::ErrorKind; use clap::CommandFactory; diff --git a/src/cli/radarr/list_command_handler_tests.rs b/src/cli/radarr/list_command_handler_tests.rs index 4c7446a..2252d03 100644 --- a/src/cli/radarr/list_command_handler_tests.rs +++ b/src/cli/radarr/list_command_handler_tests.rs @@ -130,7 +130,7 @@ mod tests { #[case(RadarrListCommand::Tasks, RadarrEvent::GetTasks)] #[case(RadarrListCommand::Updates, RadarrEvent::GetUpdates)] #[tokio::test] - async fn test_handle_list_blocklist_command( + async fn test_handle_list_command( #[case] list_command: RadarrListCommand, #[case] expected_radarr_event: RadarrEvent, ) { diff --git a/src/cli/sonarr/get_command_handler.rs b/src/cli/sonarr/get_command_handler.rs new file mode 100644 index 0000000..1e361b9 --- /dev/null +++ b/src/cli/sonarr/get_command_handler.rs @@ -0,0 +1,60 @@ +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 = "get_command_handler_tests.rs"] +mod get_command_handler_tests; + +#[derive(Debug, Clone, PartialEq, Eq, Subcommand)] +pub enum SonarrGetCommand { + #[command(about = "Get the system status")] + SystemStatus, +} + +impl From for Command { + fn from(value: SonarrGetCommand) -> Self { + Command::Sonarr(SonarrCommand::Get(value)) + } +} + +pub(super) struct SonarrGetCommandHandler<'a, 'b> { + _app: &'a Arc>>, + command: SonarrGetCommand, + network: &'a mut dyn NetworkTrait, +} + +impl<'a, 'b> CliCommandHandler<'a, 'b, SonarrGetCommand> for SonarrGetCommandHandler<'a, 'b> { + fn with( + _app: &'a Arc>>, + command: SonarrGetCommand, + network: &'a mut dyn NetworkTrait, + ) -> Self { + SonarrGetCommandHandler { + _app, + command, + network, + } + } + + async fn handle(self) -> Result<()> { + match self.command { + SonarrGetCommand::SystemStatus => { + execute_network_event!(self, SonarrEvent::GetStatus); + } + } + + Ok(()) + } +} diff --git a/src/cli/sonarr/get_command_handler_tests.rs b/src/cli/sonarr/get_command_handler_tests.rs new file mode 100644 index 0000000..3bddd8d --- /dev/null +++ b/src/cli/sonarr/get_command_handler_tests.rs @@ -0,0 +1,71 @@ +#[cfg(test)] +mod tests { + use crate::cli::{ + sonarr::{get_command_handler::SonarrGetCommand, SonarrCommand}, + Command, + }; + use crate::Cli; + use clap::CommandFactory; + + #[test] + fn test_sonarr_get_command_from() { + let command = SonarrGetCommand::SystemStatus; + + let result = Command::from(command.clone()); + + assert_eq!(result, Command::Sonarr(SonarrCommand::Get(command))); + } + + mod cli { + use super::*; + + #[test] + fn test_system_status_has_no_arg_requirements() { + let result = + Cli::command().try_get_matches_from(["managarr", "radarr", "get", "system-status"]); + + assert!(result.is_ok()); + } + } + + mod handler { + use std::sync::Arc; + + use mockall::predicate::eq; + use serde_json::json; + use tokio::sync::Mutex; + + use crate::{ + app::App, + cli::{ + sonarr::get_command_handler::{SonarrGetCommand, SonarrGetCommandHandler}, + CliCommandHandler, + }, + models::{sonarr_models::SonarrSerdeable, Serdeable}, + network::{sonarr_network::SonarrEvent, MockNetworkTrait, NetworkEvent}, + }; + + #[tokio::test] + async fn test_handle_get_system_status_command() { + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::(SonarrEvent::GetStatus.into())) + .times(1) + .returning(|_| { + Ok(Serdeable::Sonarr(SonarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::default())); + let get_system_status_command = SonarrGetCommand::SystemStatus; + + let result = + SonarrGetCommandHandler::with(&app_arc, get_system_status_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 new file mode 100644 index 0000000..6ca0d51 --- /dev/null +++ b/src/cli/sonarr/list_command_handler.rs @@ -0,0 +1,60 @@ +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 = "list_command_handler_tests.rs"] +mod list_command_handler_tests; + +#[derive(Debug, Clone, PartialEq, Eq, Subcommand)] +pub enum SonarrListCommand { + #[command(about = "List all series in your Sonarr library")] + Series, +} + +impl From for Command { + fn from(value: SonarrListCommand) -> Self { + Command::Sonarr(SonarrCommand::List(value)) + } +} + +pub(super) struct SonarrListCommandHandler<'a, 'b> { + app: &'a Arc>>, + command: SonarrListCommand, + network: &'a mut dyn NetworkTrait, +} + +impl<'a, 'b> CliCommandHandler<'a, 'b, SonarrListCommand> for SonarrListCommandHandler<'a, 'b> { + fn with( + app: &'a Arc>>, + command: SonarrListCommand, + network: &'a mut dyn NetworkTrait, + ) -> Self { + SonarrListCommandHandler { + app, + command, + network, + } + } + + async fn handle(self) -> Result<()> { + match self.command { + SonarrListCommand::Series => { + execute_network_event!(self, SonarrEvent::ListSeries); + } + } + + Ok(()) + } +} diff --git a/src/cli/sonarr/list_command_handler_tests.rs b/src/cli/sonarr/list_command_handler_tests.rs new file mode 100644 index 0000000..52d6819 --- /dev/null +++ b/src/cli/sonarr/list_command_handler_tests.rs @@ -0,0 +1,77 @@ +#[cfg(test)] +mod tests { + use crate::cli::{ + sonarr::{list_command_handler::SonarrListCommand, SonarrCommand}, + Command, + }; + use crate::Cli; + use clap::CommandFactory; + + #[test] + fn test_sonarr_list_command_from() { + let command = SonarrListCommand::Series; + + let result = Command::from(command.clone()); + + assert_eq!(result, Command::Sonarr(SonarrCommand::List(command))); + } + + mod cli { + use super::*; + use rstest::rstest; + + #[rstest] + fn test_list_commands_have_no_arg_requirements(#[values("series")] subcommand: &str) { + let result = Cli::command().try_get_matches_from(["managarr", "sonarr", "list", subcommand]); + + assert!(result.is_ok()); + } + } + + mod handler { + + use std::sync::Arc; + + use mockall::predicate::eq; + use rstest::rstest; + use serde_json::json; + use tokio::sync::Mutex; + + use crate::cli::sonarr::list_command_handler::SonarrListCommand; + use crate::cli::CliCommandHandler; + use crate::network::sonarr_network::SonarrEvent; + use crate::{ + app::App, + models::{radarr_models::RadarrSerdeable, Serdeable}, + network::{MockNetworkTrait, NetworkEvent}, + }; + + #[rstest] + #[case(SonarrListCommand::Series, SonarrEvent::ListSeries)] + #[tokio::test] + async fn test_handle_list_command( + #[case] list_command: SonarrListCommand, + #[case] expected_sonarr_event: SonarrEvent, + ) { + use crate::cli::sonarr::list_command_handler::SonarrListCommandHandler; + + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::(expected_sonarr_event.into())) + .times(1) + .returning(|_| { + Ok(Serdeable::Radarr(RadarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::default())); + + let result = SonarrListCommandHandler::with(&app_arc, list_command, &mut mock_network) + .handle() + .await; + + assert!(result.is_ok()); + } + } +} diff --git a/src/cli/sonarr/mod.rs b/src/cli/sonarr/mod.rs new file mode 100644 index 0000000..6c3b9e0 --- /dev/null +++ b/src/cli/sonarr/mod.rs @@ -0,0 +1,75 @@ +use std::sync::Arc; + +use anyhow::Result; +use clap::Subcommand; +use get_command_handler::{SonarrGetCommand, SonarrGetCommandHandler}; +use list_command_handler::{SonarrListCommand, SonarrListCommandHandler}; +use tokio::sync::Mutex; + +use crate::{app::App, network::NetworkTrait}; + +use super::{CliCommandHandler, Command}; + +mod get_command_handler; +mod list_command_handler; + +#[cfg(test)] +#[path = "sonarr_command_tests.rs"] +mod sonarr_command_tests; + +#[derive(Debug, Clone, PartialEq, Eq, Subcommand)] +pub enum SonarrCommand { + #[command( + subcommand, + about = "Commands to fetch details of the resources in your Sonarr instance" + )] + Get(SonarrGetCommand), + #[command( + subcommand, + about = "Commands to list attributes from your Sonarr instance" + )] + List(SonarrListCommand), +} + +impl From for Command { + fn from(sonarr_command: SonarrCommand) -> Command { + Command::Sonarr(sonarr_command) + } +} + +pub(super) struct SonarrCliHandler<'a, 'b> { + app: &'a Arc>>, + command: SonarrCommand, + network: &'a mut dyn NetworkTrait, +} + +impl<'a, 'b> CliCommandHandler<'a, 'b, SonarrCommand> for SonarrCliHandler<'a, 'b> { + fn with( + app: &'a Arc>>, + command: SonarrCommand, + network: &'a mut dyn NetworkTrait, + ) -> Self { + SonarrCliHandler { + app, + command, + network, + } + } + + async fn handle(self) -> Result<()> { + match self.command { + SonarrCommand::Get(get_command) => { + SonarrGetCommandHandler::with(self.app, get_command, self.network) + .handle() + .await? + } + SonarrCommand::List(list_command) => { + SonarrListCommandHandler::with(self.app, list_command, self.network) + .handle() + .await? + } + } + + Ok(()) + } +} diff --git a/src/cli/sonarr/sonarr_command_tests.rs b/src/cli/sonarr/sonarr_command_tests.rs new file mode 100644 index 0000000..a1840be --- /dev/null +++ b/src/cli/sonarr/sonarr_command_tests.rs @@ -0,0 +1,86 @@ +#[cfg(test)] +mod tests { + use crate::cli::{ + sonarr::{list_command_handler::SonarrListCommand, SonarrCommand}, + Command, + }; + + #[test] + fn test_sonarr_command_from() { + let command = SonarrCommand::List(SonarrListCommand::Series); + + let result = Command::from(command.clone()); + + assert_eq!(result, Command::Sonarr(command)); + } + + mod cli {} + + mod handler { + use std::sync::Arc; + + use mockall::predicate::eq; + use serde_json::json; + use tokio::sync::Mutex; + + use crate::{ + app::App, + cli::{ + sonarr::{ + get_command_handler::SonarrGetCommand, list_command_handler::SonarrListCommand, + SonarrCliHandler, SonarrCommand, + }, + CliCommandHandler, + }, + models::{ + sonarr_models::{Series, SonarrSerdeable}, + Serdeable, + }, + network::{sonarr_network::SonarrEvent, MockNetworkTrait, NetworkEvent}, + }; + + #[tokio::test] + async fn test_sonarr_cli_handler_delegates_get_commands_to_the_get_command_handler() { + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::(SonarrEvent::GetStatus.into())) + .times(1) + .returning(|_| { + Ok(Serdeable::Sonarr(SonarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::default())); + let get_system_status_command = SonarrCommand::Get(SonarrGetCommand::SystemStatus); + + let result = SonarrCliHandler::with(&app_arc, get_system_status_command, &mut mock_network) + .handle() + .await; + + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_sonarr_cli_handler_delegates_list_commands_to_the_list_command_handler() { + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::(SonarrEvent::ListSeries.into())) + .times(1) + .returning(|_| { + Ok(Serdeable::Sonarr(SonarrSerdeable::SeriesVec(vec![ + Series::default(), + ]))) + }); + let app_arc = Arc::new(Mutex::new(App::default())); + let list_series_command = SonarrCommand::List(SonarrListCommand::Series); + + let result = SonarrCliHandler::with(&app_arc, list_series_command, &mut mock_network) + .handle() + .await; + + assert!(result.is_ok()); + } + } +} diff --git a/src/main.rs b/src/main.rs index 388286b..4123f5f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -11,7 +11,7 @@ use std::{io, panic, process}; use anyhow::anyhow; use anyhow::Result; -use app::{log_and_print_error, AppConfig, ServarrConfig}; +use app::{log_and_print_error, AppConfig}; use clap::{ command, crate_authors, crate_description, crate_name, crate_version, CommandFactory, Parser, }; @@ -112,7 +112,7 @@ async fn main() -> Result<()> { match args.command { Some(command) => match command { - Command::Radarr(_) => { + Command::Radarr(_) | Command::Sonarr(_) => { let app_nw = Arc::clone(&app); let mut network = Network::new(&app_nw, cancellation_token, reqwest_client); diff --git a/src/models/mod.rs b/src/models/mod.rs index 7193066..0845624 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -6,8 +6,11 @@ use radarr_models::RadarrSerdeable; use regex::Regex; use serde::{de, Deserialize, Deserializer, Serialize, Serializer}; use serde_json::Number; +use servarr_data::sonarr::sonarr_data::ActiveSonarrBlock; +use sonarr_models::SonarrSerdeable; pub mod radarr_models; pub mod servarr_data; +pub mod sonarr_models; pub mod stateful_list; pub mod stateful_table; @@ -20,7 +23,7 @@ mod model_tests; #[derive(Clone, Copy, PartialEq, Eq, Debug)] pub enum Route { Radarr(ActiveRadarrBlock, Option), - Sonarr, + Sonarr(ActiveSonarrBlock, Option), Readarr, Lidarr, Whisparr, @@ -33,6 +36,7 @@ pub enum Route { #[serde(untagged)] pub enum Serdeable { Radarr(RadarrSerdeable), + Sonarr(SonarrSerdeable), } pub trait Scrollable { @@ -359,6 +363,16 @@ where ))) } +pub fn from_f64<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + let num: Number = Deserialize::deserialize(deserializer)?; + num.as_f64().ok_or(de::Error::custom(format!( + "Unable to convert Number to f64: {num:?}" + ))) +} + pub fn strip_non_search_characters(input: &str) -> String { Regex::new(r"[^a-zA-Z0-9.,/'\-:\s]") .unwrap() diff --git a/src/models/model_tests.rs b/src/models/model_tests.rs index 7cd2696..58880fc 100644 --- a/src/models/model_tests.rs +++ b/src/models/model_tests.rs @@ -10,6 +10,7 @@ mod tests { use serde::de::IntoDeserializer; use serde_json::to_string; + use crate::models::from_f64; use crate::models::servarr_data::radarr::radarr_data::ActiveRadarrBlock; use crate::models::{from_i64, strip_non_search_characters}; use crate::models::{ @@ -649,6 +650,13 @@ mod tests { ); } + #[test] + fn test_from_f64() { + let deserializer: F64Deserializer = 1f64.into_deserializer(); + + assert_eq!(from_f64(deserializer), Ok(1.0)); + } + #[test] fn test_horizontally_scrollable_serialize() { let text = HorizontallyScrollableText::from("Test"); diff --git a/src/models/servarr_data/mod.rs b/src/models/servarr_data/mod.rs index c430c1a..a4ec084 100644 --- a/src/models/servarr_data/mod.rs +++ b/src/models/servarr_data/mod.rs @@ -1 +1,2 @@ pub mod radarr; +pub mod sonarr; diff --git a/src/models/servarr_data/radarr/radarr_data_tests.rs b/src/models/servarr_data/radarr/radarr_data_tests.rs index a487156..1c42d9f 100644 --- a/src/models/servarr_data/radarr/radarr_data_tests.rs +++ b/src/models/servarr_data/radarr/radarr_data_tests.rs @@ -19,6 +19,14 @@ mod tests { use crate::assert_movie_info_tabs_reset; use crate::models::BlockSelectionState; + #[test] + fn test_from_active_radarr_block_to_route() { + assert_eq!( + Route::from(ActiveRadarrBlock::AddMoviePrompt), + Route::Radarr(ActiveRadarrBlock::AddMoviePrompt, None) + ); + } + #[test] fn test_from_tuple_to_route_with_context() { assert_eq!( @@ -60,7 +68,7 @@ mod tests { assert_eq!(radarr_data.disk_space_vec, Vec::new()); assert!(radarr_data.version.is_empty()); assert_eq!(radarr_data.start_time, >::default()); - assert!(radarr_data.movies.items.is_empty()); + assert!(radarr_data.movies.is_empty()); assert_eq!(radarr_data.selected_block, BlockSelectionState::default()); assert!(radarr_data.downloads.items.is_empty()); assert!(radarr_data.indexers.items.is_empty()); diff --git a/src/models/servarr_data/sonarr/mod.rs b/src/models/servarr_data/sonarr/mod.rs new file mode 100644 index 0000000..49bfe8e --- /dev/null +++ b/src/models/servarr_data/sonarr/mod.rs @@ -0,0 +1 @@ +pub mod sonarr_data; diff --git a/src/models/servarr_data/sonarr/sonarr_data.rs b/src/models/servarr_data/sonarr/sonarr_data.rs new file mode 100644 index 0000000..d43f669 --- /dev/null +++ b/src/models/servarr_data/sonarr/sonarr_data.rs @@ -0,0 +1,43 @@ +use chrono::{DateTime, Utc}; +use strum::EnumIter; + +use crate::models::{sonarr_models::Series, stateful_table::StatefulTable, Route}; + +#[cfg(test)] +#[path = "sonarr_data_tests.rs"] +mod sonarr_data_tests; + +pub struct SonarrData { + pub version: String, + pub start_time: DateTime, + pub series: StatefulTable, +} + +impl Default for SonarrData { + fn default() -> SonarrData { + SonarrData { + version: String::new(), + start_time: DateTime::default(), + series: StatefulTable::default(), + } + } +} + +#[derive(Clone, Copy, PartialEq, Eq, Debug, Default, EnumIter)] +pub enum ActiveSonarrBlock { + #[default] + Series, + SeriesSortPrompt, +} + +impl From for Route { + fn from(active_sonarr_block: ActiveSonarrBlock) -> Route { + Route::Sonarr(active_sonarr_block, None) + } +} + +impl From<(ActiveSonarrBlock, Option)> for Route { + fn from(value: (ActiveSonarrBlock, Option)) -> Route { + Route::Sonarr(value.0, value.1) + } +} diff --git a/src/models/servarr_data/sonarr/sonarr_data_tests.rs b/src/models/servarr_data/sonarr/sonarr_data_tests.rs new file mode 100644 index 0000000..cc29b79 --- /dev/null +++ b/src/models/servarr_data/sonarr/sonarr_data_tests.rs @@ -0,0 +1,42 @@ +#[cfg(test)] +mod tests { + mod sonarr_data_tests { + use chrono::{DateTime, Utc}; + + use crate::models::{ + servarr_data::sonarr::sonarr_data::{ActiveSonarrBlock, SonarrData}, + Route, + }; + + #[test] + fn test_from_active_sonarr_block_to_route() { + assert_eq!( + Route::from(ActiveSonarrBlock::SeriesSortPrompt), + Route::Sonarr(ActiveSonarrBlock::SeriesSortPrompt, None) + ); + } + + #[test] + fn test_from_tuple_to_route_with_context() { + assert_eq!( + Route::from(( + ActiveSonarrBlock::SeriesSortPrompt, + Some(ActiveSonarrBlock::Series) + )), + Route::Sonarr( + ActiveSonarrBlock::SeriesSortPrompt, + Some(ActiveSonarrBlock::Series), + ) + ); + } + + #[test] + fn test_sonarr_data_defaults() { + let sonarr_data = SonarrData::default(); + + assert!(sonarr_data.version.is_empty()); + assert_eq!(sonarr_data.start_time, >::default()); + assert!(sonarr_data.series.is_empty()); + } + } +} diff --git a/src/models/sonarr_models.rs b/src/models/sonarr_models.rs new file mode 100644 index 0000000..3b878eb --- /dev/null +++ b/src/models/sonarr_models.rs @@ -0,0 +1,207 @@ +use std::fmt::{Display, Formatter}; + +use chrono::{DateTime, Utc}; +use clap::ValueEnum; +use derivative::Derivative; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Number, Value}; +use strum::EnumIter; + +use crate::serde_enum_from; + +use super::{HorizontallyScrollableText, Serdeable}; + +#[cfg(test)] +#[path = "sonarr_models_tests.rs"] +mod sonarr_models_tests; + +#[derive(Derivative, Serialize, Deserialize, Debug, Clone, PartialEq)] +#[derivative(Default)] +pub struct Rating { + #[serde(deserialize_with = "super::from_i64")] + pub votes: i64, + #[serde(deserialize_with = "super::from_f64")] + pub value: f64, +} + +impl Eq for Rating {} + +#[derive(Derivative, Serialize, Deserialize, Debug, Default, Clone, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct Season { + #[serde(deserialize_with = "super::from_i64")] + pub season_number: i64, + pub monitored: bool, + pub statistics: SeasonStatistics, +} + +#[derive(Derivative, Serialize, Deserialize, Debug, Default, Clone, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct SeasonStatistics { + pub next_airing: Option>, + pub previous_airing: Option>, + #[serde(deserialize_with = "super::from_i64")] + pub episode_file_count: i64, + #[serde(deserialize_with = "super::from_i64")] + pub episode_count: i64, + #[serde(deserialize_with = "super::from_i64")] + pub total_episode_count: i64, + #[serde(deserialize_with = "super::from_i64")] + pub size_on_disk: i64, + #[serde(deserialize_with = "super::from_f64")] + pub percent_of_episodes: f64, +} + +impl Eq for SeasonStatistics {} + +#[derive(Derivative, Serialize, Deserialize, Debug, Default, Clone, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct Series { + #[serde(deserialize_with = "super::from_i64")] + pub id: i64, + #[serde(deserialize_with = "super::from_i64")] + pub tvdb_id: i64, + pub title: HorizontallyScrollableText, + #[serde(deserialize_with = "super::from_i64")] + pub quality_profile_id: i64, + #[serde(deserialize_with = "super::from_i64")] + pub language_profile_id: i64, + #[serde(deserialize_with = "super::from_i64")] + pub runtime: i64, + #[serde(deserialize_with = "super::from_i64")] + pub year: i64, + pub monitored: bool, + pub series_type: SeriesType, + pub path: String, + pub genres: Vec, + pub tags: Vec, + pub ratings: Rating, + pub ended: bool, + pub status: SeriesStatus, + pub overview: String, + pub network: Option, + pub season_folder: bool, + pub certification: Option, + pub statistics: Option, + pub seasons: Option>, +} + +#[derive( + Serialize, Deserialize, Default, PartialEq, Eq, Clone, Copy, Debug, EnumIter, ValueEnum, +)] +#[serde(rename_all = "camelCase")] +pub enum SeriesType { + #[default] + Standard, + Daily, + Anime, +} + +impl Display for SeriesType { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + let series_type = match self { + SeriesType::Standard => "standard", + SeriesType::Daily => "daily", + SeriesType::Anime => "anime", + }; + write!(f, "{series_type}") + } +} + +impl SeriesType { + pub fn to_display_str<'a>(self) -> &'a str { + match self { + SeriesType::Standard => "Standard", + SeriesType::Daily => "Daily", + SeriesType::Anime => "Anime", + } + } +} + +#[derive(Derivative, Serialize, Deserialize, Debug, Default, Clone, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct SeriesStatistics { + #[serde(deserialize_with = "super::from_i64")] + pub season_count: i64, + #[serde(deserialize_with = "super::from_i64")] + pub episode_file_count: i64, + #[serde(deserialize_with = "super::from_i64")] + pub episode_count: i64, + #[serde(deserialize_with = "super::from_i64")] + pub total_episode_count: i64, + #[serde(deserialize_with = "super::from_i64")] + pub size_on_disk: i64, + #[serde(deserialize_with = "super::from_f64")] + pub percent_of_episodes: f64, +} + +impl Eq for SeriesStatistics {} + +#[derive(Serialize, Deserialize, Default, PartialEq, Eq, Clone, Copy, Debug, EnumIter)] +#[serde(rename_all = "camelCase")] +pub enum SeriesStatus { + #[default] + Continuing, + Ended, + Upcoming, + Deleted, +} + +impl Display for SeriesStatus { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + let series_status = match self { + SeriesStatus::Continuing => "continuing", + SeriesStatus::Ended => "ended", + SeriesStatus::Upcoming => "upcoming", + SeriesStatus::Deleted => "deleted", + }; + write!(f, "{series_status}") + } +} + +impl SeriesStatus { + pub fn to_display_str<'a>(self) -> &'a str { + match self { + SeriesStatus::Continuing => "Continuing", + SeriesStatus::Ended => "Ended", + SeriesStatus::Upcoming => "Upcoming", + SeriesStatus::Deleted => "Deleted", + } + } +} + +#[derive(Clone, Serialize, Deserialize, Debug, PartialEq, Eq)] +#[serde(untagged)] +#[allow(clippy::large_enum_variant)] +pub enum SonarrSerdeable { + Value(Value), + SeriesVec(Vec), + SystemStatus(SystemStatus), +} + +impl From for Serdeable { + fn from(value: SonarrSerdeable) -> Serdeable { + Serdeable::Sonarr(value) + } +} + +impl From<()> for SonarrSerdeable { + fn from(_: ()) -> Self { + SonarrSerdeable::Value(json!({})) + } +} + +serde_enum_from!( + SonarrSerdeable { + Value(Value), + SeriesVec(Vec), + SystemStatus(SystemStatus), + } +); + +#[derive(Default, Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct SystemStatus { + pub version: String, + pub start_time: DateTime, +} diff --git a/src/models/sonarr_models_tests.rs b/src/models/sonarr_models_tests.rs new file mode 100644 index 0000000..15387f6 --- /dev/null +++ b/src/models/sonarr_models_tests.rs @@ -0,0 +1,92 @@ +#[cfg(test)] +mod tests { + use pretty_assertions::{assert_eq, assert_str_eq}; + use serde_json::json; + + use crate::models::{ + sonarr_models::{Series, SeriesStatus, SeriesType, SonarrSerdeable, SystemStatus}, + Serdeable, + }; + + #[test] + fn test_series_status_display() { + assert_str_eq!(SeriesStatus::Continuing.to_string(), "continuing"); + assert_str_eq!(SeriesStatus::Ended.to_string(), "ended"); + assert_str_eq!(SeriesStatus::Upcoming.to_string(), "upcoming"); + assert_str_eq!(SeriesStatus::Deleted.to_string(), "deleted"); + } + + #[test] + fn test_series_status_to_display_str() { + assert_str_eq!(SeriesStatus::Continuing.to_display_str(), "Continuing"); + assert_str_eq!(SeriesStatus::Ended.to_display_str(), "Ended"); + assert_str_eq!(SeriesStatus::Upcoming.to_display_str(), "Upcoming"); + assert_str_eq!(SeriesStatus::Deleted.to_display_str(), "Deleted"); + } + + #[test] + fn test_series_type_display() { + assert_str_eq!(SeriesType::Standard.to_string(), "standard"); + assert_str_eq!(SeriesType::Daily.to_string(), "daily"); + assert_str_eq!(SeriesType::Anime.to_string(), "anime"); + } + + #[test] + fn test_series_type_to_display_str() { + assert_str_eq!(SeriesType::Standard.to_display_str(), "Standard"); + assert_str_eq!(SeriesType::Daily.to_display_str(), "Daily"); + assert_str_eq!(SeriesType::Anime.to_display_str(), "Anime"); + } + + #[test] + fn test_sonarr_serdeable_from() { + let sonarr_serdeable = SonarrSerdeable::Value(json!({})); + + let serdeable: Serdeable = Serdeable::from(sonarr_serdeable.clone()); + + assert_eq!(serdeable, Serdeable::Sonarr(sonarr_serdeable)); + } + + #[test] + fn test_sonarr_serdeable_from_unit() { + let sonarr_serdeable = SonarrSerdeable::from(()); + + assert_eq!(sonarr_serdeable, SonarrSerdeable::Value(json!({}))); + } + + #[test] + fn test_sonarr_serdeable_from_value() { + let value = json!({"test": "test"}); + + let sonarr_serdeable: SonarrSerdeable = value.clone().into(); + + assert_eq!(sonarr_serdeable, SonarrSerdeable::Value(value)); + } + + #[test] + fn test_sonarr_serdeable_from_series() { + let series = vec![Series { + id: 1, + ..Series::default() + }]; + + let sonarr_serdeable: SonarrSerdeable = series.clone().into(); + + assert_eq!(sonarr_serdeable, SonarrSerdeable::SeriesVec(series)); + } + + #[test] + fn test_sonarr_serdeable_from_system_status() { + let system_status = SystemStatus { + version: "1".to_owned(), + ..SystemStatus::default() + }; + + let sonarr_serdeable: SonarrSerdeable = system_status.clone().into(); + + assert_eq!( + sonarr_serdeable, + SonarrSerdeable::SystemStatus(system_status) + ); + } +} diff --git a/src/network/mod.rs b/src/network/mod.rs index e3c252e..0967a46 100644 --- a/src/network/mod.rs +++ b/src/network/mod.rs @@ -8,35 +8,42 @@ use regex::Regex; use reqwest::{Client, RequestBuilder}; use serde::de::DeserializeOwned; use serde::Serialize; +use sonarr_network::SonarrEvent; use strum_macros::Display; use tokio::select; use tokio::sync::{Mutex, MutexGuard}; use tokio_util::sync::CancellationToken; -use crate::app::App; +use crate::app::{App, ServarrConfig}; use crate::models::Serdeable; use crate::network::radarr_network::RadarrEvent; #[cfg(test)] use mockall::automock; pub mod radarr_network; +pub mod sonarr_network; mod utils; #[cfg(test)] #[path = "network_tests.rs"] mod network_tests; -#[derive(PartialEq, Eq, Debug, Clone)] -pub enum NetworkEvent { - Radarr(RadarrEvent), -} - #[cfg_attr(test, automock)] #[async_trait] pub trait NetworkTrait { async fn handle_network_event(&mut self, network_event: NetworkEvent) -> Result; } +pub trait NetworkResource { + fn resource(&self) -> &'static str; +} + +#[derive(PartialEq, Eq, Debug, Clone)] +pub enum NetworkEvent { + Radarr(RadarrEvent), + Sonarr(SonarrEvent), +} + #[derive(Clone)] pub struct Network<'a, 'b> { client: Client, @@ -52,6 +59,10 @@ impl<'a, 'b> NetworkTrait for Network<'a, 'b> { .handle_radarr_event(radarr_event) .await .map(Serdeable::from), + NetworkEvent::Sonarr(sonarr_event) => self + .handle_sonarr_event(sonarr_event) + .await + .map(Serdeable::from), }; let mut app = self.app.lock().await; @@ -180,6 +191,65 @@ impl<'a, 'b> Network<'a, 'b> { .header("X-Api-Key", api_token), } } + + async fn request_props_from( + &self, + network_event: N, + method: RequestMethod, + body: Option, + path: Option, + query_params: Option, + ) -> RequestProps + where + T: Serialize + Debug, + N: Into + NetworkResource, + { + let app = self.app.lock().await; + let resource = network_event.resource(); + let ( + ServarrConfig { + host, + port, + uri, + api_token, + ssl_cert_path, + }, + default_port, + ) = match network_event.into() { + NetworkEvent::Radarr(_) => (&app.config.radarr, 7878), + NetworkEvent::Sonarr(_) => (&app.config.sonarr, 8989), + }; + let mut uri = if let Some(servarr_uri) = uri { + format!("{servarr_uri}/api/v3{resource}") + } else { + let protocol = if ssl_cert_path.is_some() { + "https" + } else { + "http" + }; + let host = host.as_ref().unwrap(); + format!( + "{protocol}://{host}:{}/api/v3{resource}", + port.unwrap_or(default_port) + ) + }; + + if let Some(path) = path { + uri = format!("{uri}{path}"); + } + + if let Some(params) = query_params { + uri = format!("{uri}?{params}"); + } + + RequestProps { + uri, + method, + body, + api_token: api_token.to_owned(), + ignore_status_code: false, + } + } } #[derive(Clone, Copy, Debug, Display, PartialEq, Eq)] diff --git a/src/network/network_tests.rs b/src/network/network_tests.rs index 5528a0f..109ac71 100644 --- a/src/network/network_tests.rs +++ b/src/network/network_tests.rs @@ -12,9 +12,11 @@ mod tests { use tokio::sync::{mpsc, Mutex}; use tokio_util::sync::CancellationToken; - use crate::app::{App, AppConfig, RadarrConfig}; + use crate::app::{App, AppConfig, ServarrConfig}; use crate::models::HorizontallyScrollableText; use crate::network::radarr_network::RadarrEvent; + use crate::network::sonarr_network::SonarrEvent; + use crate::network::NetworkResource; use crate::network::{Network, NetworkEvent, NetworkTrait, RequestMethod, RequestProps}; #[tokio::test] @@ -34,12 +36,12 @@ mod tests { ); let mut app = App::default(); app.is_loading = true; - let radarr_config = RadarrConfig { + let radarr_config = ServarrConfig { host, api_token: String::new(), port, ssl_cert_path: None, - ..RadarrConfig::default() + ..ServarrConfig::default() }; app.config.radarr = radarr_config; let app_arc = Arc::new(Mutex::new(app)); @@ -395,6 +397,214 @@ mod tests { async_server.assert_async().await; } + #[rstest] + #[case(RadarrEvent::GetMovies, 7878)] + #[case(SonarrEvent::ListSeries, 8989)] + #[tokio::test] + async fn test_request_props_from_default_config( + #[case] network_event: impl Into + NetworkResource, + #[case] default_port: u16, + ) { + let app_arc = Arc::new(Mutex::new(App::default())); + let network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + let resource = network_event.resource(); + + let request_props = network + .request_props_from(network_event, RequestMethod::Get, None::<()>, None, None) + .await; + + assert_str_eq!( + request_props.uri, + format!("http://localhost:{default_port}/api/v3{resource}") + ); + assert_eq!(request_props.method, RequestMethod::Get); + assert_eq!(request_props.body, None); + assert!(request_props.api_token.is_empty()); + } + + #[rstest] + #[tokio::test] + async fn test_request_props_from_custom_config( + #[values(RadarrEvent::GetMovies, SonarrEvent::ListSeries)] network_event: impl Into + + NetworkResource, + ) { + let api_token = "testToken1234".to_owned(); + let app_arc = Arc::new(Mutex::new(App::default())); + let resource = network_event.resource(); + let servarr_config = ServarrConfig { + host: Some("192.168.0.123".to_owned()), + port: Some(8080), + api_token: api_token.clone(), + ssl_cert_path: Some("/test/cert.crt".to_owned()), + ..ServarrConfig::default() + }; + { + let mut app = app_arc.lock().await; + app.config.radarr = servarr_config.clone(); + app.config.sonarr = servarr_config; + } + let network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + let request_props = network + .request_props_from(network_event, RequestMethod::Get, None::<()>, None, None) + .await; + + assert_str_eq!( + request_props.uri, + format!("https://192.168.0.123:8080/api/v3{resource}") + ); + assert_eq!(request_props.method, RequestMethod::Get); + assert_eq!(request_props.body, None); + assert_str_eq!(request_props.api_token, api_token); + } + + #[rstest] + #[tokio::test] + async fn test_request_props_from_custom_config_using_uri_instead_of_host_and_port( + #[values(RadarrEvent::GetMovies, SonarrEvent::ListSeries)] network_event: impl Into + + NetworkResource, + ) { + let api_token = "testToken1234".to_owned(); + let app_arc = Arc::new(Mutex::new(App::default())); + let resource = network_event.resource(); + let servarr_config = ServarrConfig { + uri: Some("https://192.168.0.123:8080".to_owned()), + api_token: api_token.clone(), + ..ServarrConfig::default() + }; + { + let mut app = app_arc.lock().await; + app.config.radarr = servarr_config.clone(); + app.config.sonarr = servarr_config; + } + let network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + let request_props = network + .request_props_from(network_event, RequestMethod::Get, None::<()>, None, None) + .await; + + assert_str_eq!( + request_props.uri, + format!("https://192.168.0.123:8080/api/v3{resource}") + ); + assert_eq!(request_props.method, RequestMethod::Get); + assert_eq!(request_props.body, None); + assert_str_eq!(request_props.api_token, api_token); + } + + #[rstest] + #[case(RadarrEvent::GetMovies, 7878)] + #[case(SonarrEvent::ListSeries, 8989)] + #[tokio::test] + async fn test_request_props_from_default_config_with_path_and_query_params( + #[case] network_event: impl Into + NetworkResource, + #[case] default_port: u16, + ) { + let app_arc = Arc::new(Mutex::new(App::default())); + let network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + let resource = network_event.resource(); + + let request_props = network + .request_props_from( + network_event, + RequestMethod::Get, + None::<()>, + Some("/test".to_owned()), + Some("id=1".to_owned()), + ) + .await; + + assert_str_eq!( + request_props.uri, + format!("http://localhost:{default_port}/api/v3{resource}/test?id=1") + ); + assert_eq!(request_props.method, RequestMethod::Get); + assert_eq!(request_props.body, None); + assert!(request_props.api_token.is_empty()); + } + + #[rstest] + #[tokio::test] + async fn test_request_props_from_custom_config_with_path_and_query_params( + #[values(RadarrEvent::GetMovies, SonarrEvent::ListSeries)] network_event: impl Into + + NetworkResource, + ) { + let api_token = "testToken1234".to_owned(); + let app_arc = Arc::new(Mutex::new(App::default())); + let resource = network_event.resource(); + let servarr_config = ServarrConfig { + host: Some("192.168.0.123".to_owned()), + port: Some(8080), + api_token: api_token.clone(), + ssl_cert_path: Some("/test/cert.crt".to_owned()), + ..ServarrConfig::default() + }; + { + let mut app = app_arc.lock().await; + app.config.radarr = servarr_config.clone(); + app.config.sonarr = servarr_config; + } + let network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + let request_props = network + .request_props_from( + network_event, + RequestMethod::Get, + None::<()>, + Some("/test".to_owned()), + Some("id=1".to_owned()), + ) + .await; + + assert_str_eq!( + request_props.uri, + format!("https://192.168.0.123:8080/api/v3{resource}/test?id=1") + ); + assert_eq!(request_props.method, RequestMethod::Get); + assert_eq!(request_props.body, None); + assert_str_eq!(request_props.api_token, api_token); + } + + #[rstest] + #[tokio::test] + async fn test_request_props_from_custom_config_using_uri_instead_of_host_and_port_with_path_and_query_params( + #[values(RadarrEvent::GetMovies, SonarrEvent::ListSeries)] network_event: impl Into + + NetworkResource, + ) { + let api_token = "testToken1234".to_owned(); + let app_arc = Arc::new(Mutex::new(App::default())); + let resource = network_event.resource(); + let servarr_config = ServarrConfig { + uri: Some("https://192.168.0.123:8080".to_owned()), + api_token: api_token.clone(), + ..ServarrConfig::default() + }; + { + let mut app = app_arc.lock().await; + app.config.radarr = servarr_config.clone(); + app.config.sonarr = servarr_config; + } + let network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + let request_props = network + .request_props_from( + network_event, + RequestMethod::Get, + None::<()>, + Some("/test".to_owned()), + Some("id=1".to_owned()), + ) + .await; + + assert_str_eq!( + request_props.uri, + format!("https://192.168.0.123:8080/api/v3{resource}/test?id=1") + ); + assert_eq!(request_props.method, RequestMethod::Get); + assert_eq!(request_props.body, None); + assert_str_eq!(request_props.api_token, api_token); + } + #[derive(Clone, Serialize, Deserialize, Debug, Default, PartialEq, Eq)] struct Test { pub value: String, @@ -425,3 +635,78 @@ mod tests { (async_server, app_arc, server) } } + +#[cfg(test)] +pub(in crate::network) mod test_utils { + use std::sync::Arc; + + use mockito::{Matcher, Mock, Server, ServerGuard}; + use serde_json::Value; + use tokio::sync::Mutex; + + use crate::{ + app::{App, ServarrConfig}, + network::{NetworkEvent, NetworkResource, RequestMethod}, + }; + + pub async fn mock_servarr_api<'a>( + method: RequestMethod, + request_body: Option, + response_body: Option, + response_status: Option, + network_event: impl Into + NetworkResource, + path: Option<&str>, + query_params: Option<&str>, + ) -> (Mock, Arc>>, ServerGuard) { + let status = response_status.unwrap_or(200); + let resource = network_event.resource(); + let mut server = Server::new_async().await; + let mut uri = format!("/api/v3{resource}"); + + if let Some(path) = path { + uri = format!("{uri}{path}"); + } + + if let Some(params) = query_params { + uri = format!("{uri}?{params}"); + } + + let mut async_server = server + .mock(&method.to_string().to_uppercase(), uri.as_str()) + .match_header("X-Api-Key", "test1234") + .with_status(status); + + if let Some(body) = request_body { + async_server = async_server.match_body(Matcher::Json(body)); + } + + if let Some(body) = response_body { + async_server = async_server.with_body(body.to_string()); + } + + async_server = async_server.create_async().await; + + let host = Some(server.host_with_port().split(':').collect::>()[0].to_owned()); + let port = Some( + server.host_with_port().split(':').collect::>()[1] + .parse() + .unwrap(), + ); + let mut app = App::default(); + let servarr_config = ServarrConfig { + host, + port, + api_token: "test1234".to_owned(), + ..ServarrConfig::default() + }; + + match network_event.into() { + NetworkEvent::Radarr(_) => app.config.radarr = servarr_config, + NetworkEvent::Sonarr(_) => app.config.sonarr = servarr_config, + } + + let app_arc = Arc::new(Mutex::new(app)); + + (async_server, app_arc, server) + } +} diff --git a/src/network/radarr_network.rs b/src/network/radarr_network.rs index 078dae0..2c13156 100644 --- a/src/network/radarr_network.rs +++ b/src/network/radarr_network.rs @@ -3,11 +3,9 @@ use std::fmt::Debug; use indoc::formatdoc; use log::{debug, info, warn}; -use serde::Serialize; use serde_json::{json, Value}; use urlencoding::encode; -use crate::app::RadarrConfig; use crate::models::radarr_models::{ AddMovieBody, AddMovieSearchResult, AddOptions, AddRootFolderBody, BlocklistResponse, Collection, CollectionMovie, CommandBody, Credit, CreditType, DeleteMovieParams, DiskSpace, DownloadRecord, @@ -23,9 +21,11 @@ use crate::models::servarr_data::radarr::modals::{ use crate::models::servarr_data::radarr::radarr_data::ActiveRadarrBlock; use crate::models::stateful_table::StatefulTable; use crate::models::{HorizontallyScrollableText, Route, Scrollable, ScrollableText}; -use crate::network::{Network, NetworkEvent, RequestMethod, RequestProps}; +use crate::network::{Network, NetworkEvent, RequestMethod}; use crate::utils::{convert_runtime, convert_to_gb}; +use super::NetworkResource; + #[cfg(test)] #[path = "radarr_network_tests.rs"] mod radarr_network_tests; @@ -80,8 +80,8 @@ pub enum RadarrEvent { UpdateDownloads, } -impl RadarrEvent { - const fn resource(&self) -> &'static str { +impl NetworkResource for RadarrEvent { + fn resource(&self) -> &'static str { match &self { RadarrEvent::ClearBlocklist => "/blocklist/bulk", RadarrEvent::DeleteBlocklistItem(_) => "/blocklist", @@ -214,11 +214,14 @@ impl<'a, 'b> Network<'a, 'b> { } RadarrEvent::GetRootFolders => self.get_root_folders().await.map(RadarrSerdeable::from), RadarrEvent::GetSecurityConfig => self.get_security_config().await.map(RadarrSerdeable::from), - RadarrEvent::GetStatus => self.get_status().await.map(RadarrSerdeable::from), + RadarrEvent::GetStatus => self.get_radarr_status().await.map(RadarrSerdeable::from), RadarrEvent::GetTags => self.get_tags().await.map(RadarrSerdeable::from), RadarrEvent::GetTasks => self.get_tasks().await.map(RadarrSerdeable::from), RadarrEvent::GetUpdates => self.get_updates().await.map(RadarrSerdeable::from), - RadarrEvent::HealthCheck => self.get_healthcheck().await.map(RadarrSerdeable::from), + RadarrEvent::HealthCheck => self + .get_radarr_healthcheck() + .await + .map(RadarrSerdeable::from), RadarrEvent::SearchNewMovie(query) => { self.search_movie(query).await.map(RadarrSerdeable::from) } @@ -246,6 +249,7 @@ impl<'a, 'b> Network<'a, 'b> { async fn add_movie(&mut self, add_movie_body_option: Option) -> Result { info!("Adding new movie to Radarr"); + let event = RadarrEvent::AddMovie(None); let body = if let Some(add_movie_body) = add_movie_body_option { add_movie_body } else { @@ -337,11 +341,7 @@ impl<'a, 'b> Network<'a, 'b> { debug!("Add movie body: {body:?}"); let request_props = self - .radarr_request_props_from( - RadarrEvent::AddMovie(None).resource(), - RequestMethod::Post, - Some(body), - ) + .request_props_from(event, RequestMethod::Post, Some(body), None, None) .await; self @@ -351,6 +351,7 @@ impl<'a, 'b> Network<'a, 'b> { async fn add_root_folder(&mut self, root_folder: Option) -> Result { info!("Adding new root folder to Radarr"); + let event = RadarrEvent::AddRootFolder(None); let body = if let Some(path) = root_folder { AddRootFolderBody { path } } else { @@ -372,11 +373,7 @@ impl<'a, 'b> Network<'a, 'b> { debug!("Add root folder body: {body:?}"); let request_props = self - .radarr_request_props_from( - RadarrEvent::AddRootFolder(None).resource(), - RequestMethod::Post, - Some(body), - ) + .request_props_from(event, RequestMethod::Post, Some(body), None, None) .await; self @@ -386,12 +383,15 @@ impl<'a, 'b> Network<'a, 'b> { async fn add_tag(&mut self, tag: String) -> Result { info!("Adding a new Radarr tag"); + let event = RadarrEvent::AddTag(String::new()); let request_props = self - .radarr_request_props_from( - RadarrEvent::AddTag(String::new()).resource(), + .request_props_from( + event, RequestMethod::Post, Some(json!({ "label": tag })), + None, + None, ) .await; @@ -404,12 +404,15 @@ impl<'a, 'b> Network<'a, 'b> { async fn delete_tag(&mut self, id: i64) -> Result<()> { info!("Deleting Radarr tag with id: {id}"); + let event = RadarrEvent::DeleteTag(id); let request_props = self - .radarr_request_props_from( - format!("{}/{id}", RadarrEvent::DeleteTag(id).resource()).as_str(), + .request_props_from( + event, RequestMethod::Delete, None::<()>, + Some(format!("/{id}")), + None, ) .await; @@ -420,6 +423,7 @@ impl<'a, 'b> Network<'a, 'b> { async fn clear_blocklist(&mut self) -> Result<()> { info!("Clearing Radarr blocklist"); + let event = RadarrEvent::ClearBlocklist; let ids = self .app @@ -434,10 +438,12 @@ impl<'a, 'b> Network<'a, 'b> { .collect::>(); let request_props = self - .radarr_request_props_from( - RadarrEvent::ClearBlocklist.resource(), + .request_props_from( + event, RequestMethod::Delete, Some(json!({"ids": ids})), + None, + None, ) .await; @@ -447,6 +453,7 @@ impl<'a, 'b> Network<'a, 'b> { } async fn delete_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 } else { @@ -464,10 +471,12 @@ impl<'a, 'b> Network<'a, 'b> { info!("Deleting Radarr blocklist item for item with id: {id}"); let request_props = self - .radarr_request_props_from( - format!("{}/{id}", RadarrEvent::DeleteBlocklistItem(None).resource()).as_str(), + .request_props_from( + event, RequestMethod::Delete, None::<()>, + Some(format!("/{id}")), + None, ) .await; @@ -477,6 +486,7 @@ impl<'a, 'b> Network<'a, 'b> { } async fn delete_download(&mut self, download_id: Option) -> Result<()> { + let event = RadarrEvent::DeleteDownload(None); let id = if let Some(dl_id) = download_id { dl_id } else { @@ -494,10 +504,12 @@ impl<'a, 'b> Network<'a, 'b> { info!("Deleting Radarr download for download with id: {id}"); let request_props = self - .radarr_request_props_from( - format!("{}/{id}", RadarrEvent::DeleteDownload(None).resource()).as_str(), + .request_props_from( + event, RequestMethod::Delete, None::<()>, + Some(format!("/{id}")), + None, ) .await; @@ -507,6 +519,7 @@ impl<'a, 'b> Network<'a, 'b> { } async fn delete_indexer(&mut self, indexer_id: Option) -> Result<()> { + let event = RadarrEvent::DeleteIndexer(None); let id = if let Some(i_id) = indexer_id { i_id } else { @@ -524,10 +537,12 @@ impl<'a, 'b> Network<'a, 'b> { info!("Deleting Radarr indexer for indexer with id: {id}"); let request_props = self - .radarr_request_props_from( - format!("{}/{id}", RadarrEvent::DeleteIndexer(None).resource()).as_str(), + .request_props_from( + event, RequestMethod::Delete, None::<()>, + Some(format!("/{id}")), + None, ) .await; @@ -537,6 +552,7 @@ impl<'a, 'b> Network<'a, 'b> { } async fn delete_movie(&mut self, delete_movie_params: Option) -> Result<()> { + let event = RadarrEvent::DeleteMovie(None); let (movie_id, delete_files, add_import_exclusion) = if let Some(params) = delete_movie_params { ( params.id, @@ -544,7 +560,7 @@ impl<'a, 'b> Network<'a, 'b> { params.add_list_exclusion, ) } else { - let movie_id = self.extract_movie_id().await; + let (movie_id, _) = self.extract_movie_id(None).await; let delete_files = self.app.lock().await.data.radarr_data.delete_movie_files; let add_import_exclusion = self.app.lock().await.data.radarr_data.add_list_exclusion; @@ -554,14 +570,14 @@ impl<'a, 'b> Network<'a, 'b> { info!("Deleting Radarr movie with ID: {movie_id} with deleteFiles={delete_files} and addImportExclusion={add_import_exclusion}"); let request_props = self - .radarr_request_props_from( - format!( - "{}/{movie_id}?deleteFiles={delete_files}&addImportExclusion={add_import_exclusion}", - RadarrEvent::DeleteMovie(None).resource() - ) - .as_str(), + .request_props_from( + event, RequestMethod::Delete, None::<()>, + Some(format!("/{movie_id}")), + Some(format!( + "deleteFiles={delete_files}&addImportExclusion={add_import_exclusion}" + )), ) .await; @@ -581,6 +597,7 @@ impl<'a, 'b> Network<'a, 'b> { } async fn delete_root_folder(&mut self, root_folder_id: Option) -> Result<()> { + let event = RadarrEvent::DeleteRootFolder(None); let id = if let Some(rf_id) = root_folder_id { rf_id } else { @@ -598,10 +615,12 @@ impl<'a, 'b> Network<'a, 'b> { info!("Deleting Radarr root folder for folder with id: {id}"); let request_props = self - .radarr_request_props_from( - format!("{}/{id}", RadarrEvent::DeleteRootFolder(None).resource()).as_str(), + .request_props_from( + event, RequestMethod::Delete, None::<()>, + Some(format!("/{id}")), + None, ) .await; @@ -611,11 +630,12 @@ impl<'a, 'b> Network<'a, 'b> { } async fn download_release(&mut self, params: Option) -> Result { + let event = RadarrEvent::DownloadRelease(None); let body = if let Some(release_download_body) = params { info!("Downloading release with params: {release_download_body:?}"); release_download_body } else { - let movie_id = self.extract_movie_id().await; + let (movie_id, _) = self.extract_movie_id(None).await; let (guid, title, indexer_id) = { let app = self.app.lock().await; let Release { @@ -645,11 +665,7 @@ impl<'a, 'b> Network<'a, 'b> { }; let request_props = self - .radarr_request_props_from( - RadarrEvent::DownloadRelease(None).resource(), - RequestMethod::Post, - Some(body), - ) + .request_props_from(event, RequestMethod::Post, Some(body), None, None) .await; self @@ -659,6 +675,7 @@ impl<'a, 'b> Network<'a, 'b> { async fn edit_all_indexer_settings(&mut self, params: Option) -> Result { info!("Updating Radarr indexer settings"); + let event = RadarrEvent::EditAllIndexerSettings(None); let body = if let Some(indexer_settings) = params { indexer_settings @@ -678,11 +695,7 @@ impl<'a, 'b> Network<'a, 'b> { debug!("Indexer settings body: {body:?}"); let request_props = self - .radarr_request_props_from( - RadarrEvent::EditAllIndexerSettings(None).resource(), - RequestMethod::Put, - Some(body), - ) + .request_props_from(event, RequestMethod::Put, Some(body), None, None) .await; let resp = self @@ -699,18 +712,22 @@ impl<'a, 'b> Network<'a, 'b> { edit_collection_params: Option, ) -> Result<()> { info!("Editing Radarr collection"); - + let detail_event = RadarrEvent::GetCollections; + let event = RadarrEvent::EditCollection(None); info!("Fetching collection details"); + let collection_id = if let Some(ref params) = edit_collection_params { params.collection_id } else { self.extract_collection_id().await }; let request_props = self - .radarr_request_props_from( - format!("{}/{collection_id}", RadarrEvent::GetCollections.resource()).as_str(), + .request_props_from( + detail_event, RequestMethod::Get, None::<()>, + Some(format!("/{collection_id}")), + None, ) .await; @@ -811,14 +828,12 @@ impl<'a, 'b> Network<'a, 'b> { debug!("Edit collection body: {detailed_collection_body:?}"); let request_props = self - .radarr_request_props_from( - format!( - "{}/{collection_id}", - RadarrEvent::EditCollection(None).resource() - ) - .as_str(), + .request_props_from( + event, RequestMethod::Put, Some(detailed_collection_body), + Some(format!("/{collection_id}")), + None, ) .await; @@ -828,6 +843,8 @@ impl<'a, 'b> Network<'a, 'b> { } async fn edit_indexer(&mut self, edit_indexer_params: Option) -> Result<()> { + let detail_event = RadarrEvent::GetIndexers; + let event = RadarrEvent::EditIndexer(None); let id = if let Some(ref params) = edit_indexer_params { params.indexer_id } else { @@ -846,10 +863,12 @@ impl<'a, 'b> Network<'a, 'b> { info!("Fetching indexer details for indexer with ID: {id}"); let request_props = self - .radarr_request_props_from( - format!("{}/{id}", RadarrEvent::GetIndexers.resource()).as_str(), + .request_props_from( + detail_event, RequestMethod::Get, None::<()>, + Some(format!("/{id}")), + None, ) .await; @@ -1061,10 +1080,12 @@ impl<'a, 'b> Network<'a, 'b> { debug!("Edit indexer body: {detailed_indexer_body:?}"); let request_props = self - .radarr_request_props_from( - format!("{}/{id}", RadarrEvent::EditIndexer(None).resource()).as_str(), + .request_props_from( + event, RequestMethod::Put, Some(detailed_indexer_body), + Some(format!("/{id}")), + None, ) .await; @@ -1075,23 +1096,23 @@ impl<'a, 'b> Network<'a, 'b> { async fn edit_movie(&mut self, edit_movie_params: Option) -> Result<()> { info!("Editing Radarr movie"); + let detail_event = RadarrEvent::GetMovieDetails(None); + let event = RadarrEvent::EditMovie(None); - let movie_id = if let Some(ref params) = edit_movie_params { - params.movie_id + let (movie_id, _) = if let Some(ref params) = edit_movie_params { + self.extract_movie_id(Some(params.movie_id)).await } else { - self.extract_movie_id().await + self.extract_movie_id(None).await }; info!("Fetching movie details for movie with ID: {movie_id}"); let request_props = self - .radarr_request_props_from( - format!( - "{}/{movie_id}", - RadarrEvent::GetMovieDetails(None).resource() - ) - .as_str(), + .request_props_from( + detail_event, RequestMethod::Get, None::<()>, + Some(format!("/{movie_id}")), + None, ) .await; @@ -1209,10 +1230,12 @@ impl<'a, 'b> Network<'a, 'b> { debug!("Edit movie body: {detailed_movie_body:?}"); let request_props = self - .radarr_request_props_from( - format!("{}/{movie_id}", RadarrEvent::EditMovie(None).resource()).as_str(), + .request_props_from( + event, RequestMethod::Put, Some(detailed_movie_body), + Some(format!("/{movie_id}")), + None, ) .await; @@ -1223,13 +1246,10 @@ impl<'a, 'b> Network<'a, 'b> { async fn get_blocklist(&mut self) -> Result { info!("Fetching blocklist"); + let event = RadarrEvent::GetBlocklist; let request_props = self - .radarr_request_props_from( - RadarrEvent::GetBlocklist.resource(), - RequestMethod::Get, - None::<()>, - ) + .request_props_from(event, RequestMethod::Get, None::<()>, None, None) .await; self @@ -1249,13 +1269,10 @@ impl<'a, 'b> Network<'a, 'b> { async fn get_collections(&mut self) -> Result> { info!("Fetching Radarr collections"); + let event = RadarrEvent::GetCollections; let request_props = self - .radarr_request_props_from( - RadarrEvent::GetCollections.resource(), - RequestMethod::Get, - None::<()>, - ) + .request_props_from(event, RequestMethod::Get, None::<()>, None, None) .await; self @@ -1274,12 +1291,17 @@ impl<'a, 'b> Network<'a, 'b> { async fn get_credits(&mut self, movie_id: Option) -> Result> { info!("Fetching Radarr movie credits"); + let event = RadarrEvent::GetMovieCredits(None); + let (_, movie_id_param) = self.extract_movie_id(movie_id).await; - let request_uri = self - .append_movie_id_param(RadarrEvent::GetMovieCredits(None).resource(), movie_id) - .await; let request_props = self - .radarr_request_props_from(request_uri.as_str(), RequestMethod::Get, None::<()>) + .request_props_from( + event, + RequestMethod::Get, + None::<()>, + None, + Some(movie_id_param), + ) .await; self @@ -1321,13 +1343,10 @@ impl<'a, 'b> Network<'a, 'b> { async fn get_diskspace(&mut self) -> Result> { info!("Fetching Radarr disk space"); + let event = RadarrEvent::GetOverview; let request_props = self - .radarr_request_props_from( - RadarrEvent::GetOverview.resource(), - RequestMethod::Get, - None::<()>, - ) + .request_props_from(event, RequestMethod::Get, None::<()>, None, None) .await; self @@ -1339,13 +1358,10 @@ impl<'a, 'b> Network<'a, 'b> { async fn get_downloads(&mut self) -> Result { info!("Fetching Radarr downloads"); + let event = RadarrEvent::GetDownloads; let request_props = self - .radarr_request_props_from( - RadarrEvent::GetDownloads.resource(), - RequestMethod::Get, - None::<()>, - ) + .request_props_from(event, RequestMethod::Get, None::<()>, None, None) .await; self @@ -1361,13 +1377,10 @@ impl<'a, 'b> Network<'a, 'b> { async fn get_host_config(&mut self) -> Result { info!("Fetching Radarr host config"); + let event = RadarrEvent::GetHostConfig; let request_props = self - .radarr_request_props_from( - RadarrEvent::GetHostConfig.resource(), - RequestMethod::Get, - None::<()>, - ) + .request_props_from(event, RequestMethod::Get, None::<()>, None, None) .await; self @@ -1377,13 +1390,10 @@ impl<'a, 'b> Network<'a, 'b> { async fn get_indexers(&mut self) -> Result> { info!("Fetching Radarr indexers"); + let event = RadarrEvent::GetIndexers; let request_props = self - .radarr_request_props_from( - RadarrEvent::GetIndexers.resource(), - RequestMethod::Get, - None::<()>, - ) + .request_props_from(event, RequestMethod::Get, None::<()>, None, None) .await; self @@ -1395,13 +1405,10 @@ impl<'a, 'b> Network<'a, 'b> { async fn get_all_indexer_settings(&mut self) -> Result { info!("Fetching Radarr indexer settings"); + let event = RadarrEvent::GetAllIndexerSettings; let request_props = self - .radarr_request_props_from( - RadarrEvent::GetAllIndexerSettings.resource(), - RequestMethod::Get, - None::<()>, - ) + .request_props_from(event, RequestMethod::Get, None::<()>, None, None) .await; self @@ -1415,15 +1422,12 @@ impl<'a, 'b> Network<'a, 'b> { .await } - async fn get_healthcheck(&mut self) -> Result<()> { + async fn get_radarr_healthcheck(&mut self) -> Result<()> { info!("Performing Radarr health check"); + let event = RadarrEvent::HealthCheck; let request_props = self - .radarr_request_props_from( - RadarrEvent::HealthCheck.resource(), - RequestMethod::Get, - None::<()>, - ) + .request_props_from(event, RequestMethod::Get, None::<()>, None, None) .await; self @@ -1433,14 +1437,14 @@ impl<'a, 'b> Network<'a, 'b> { async fn get_logs(&mut self, events: Option) -> Result { info!("Fetching Radarr logs"); + let event = RadarrEvent::GetLogs(events); - let resource = format!( - "{}?pageSize={}&sortDirection=descending&sortKey=time", - RadarrEvent::GetLogs(events).resource(), + let params = format!( + "pageSize={}&sortDirection=descending&sortKey=time", events.unwrap_or(500) ); let request_props = self - .radarr_request_props_from(&resource, RequestMethod::Get, None::<()>) + .request_props_from(event, RequestMethod::Get, None::<()>, None, Some(params)) .await; self @@ -1480,19 +1484,18 @@ impl<'a, 'b> Network<'a, 'b> { async fn get_movie_details(&mut self, movie_id: Option) -> Result { info!("Fetching Radarr movie details"); + let event = RadarrEvent::GetMovieDetails(None); + let (id, _) = self.extract_movie_id(movie_id).await; - let id = if let Some(m_id) = movie_id { - m_id - } else { - self.extract_movie_id().await - }; info!("Fetching movie details for movie with ID: {id}"); let request_props = self - .radarr_request_props_from( - format!("{}/{id}", RadarrEvent::GetMovieDetails(None).resource()).as_str(), + .request_props_from( + event, RequestMethod::Get, None::<()>, + Some(format!("/{id}")), + None, ) .await; @@ -1637,12 +1640,17 @@ impl<'a, 'b> Network<'a, 'b> { async fn get_movie_history(&mut self, movie_id: Option) -> Result> { info!("Fetching Radarr movie history"); + let event = RadarrEvent::GetMovieHistory(None); - let request_uri = self - .append_movie_id_param(RadarrEvent::GetMovieHistory(None).resource(), movie_id) - .await; + let (_, movie_id_param) = self.extract_movie_id(movie_id).await; let request_props = self - .radarr_request_props_from(request_uri.as_str(), RequestMethod::Get, None::<()>) + .request_props_from( + event, + RequestMethod::Get, + None::<()>, + None, + Some(movie_id_param), + ) .await; self @@ -1668,13 +1676,10 @@ impl<'a, 'b> Network<'a, 'b> { async fn get_movies(&mut self) -> Result> { info!("Fetching Radarr library"); + let event = RadarrEvent::GetMovies; let request_props = self - .radarr_request_props_from( - RadarrEvent::GetMovies.resource(), - RequestMethod::Get, - None::<()>, - ) + .request_props_from(event, RequestMethod::Get, None::<()>, None, None) .await; self @@ -1693,13 +1698,10 @@ impl<'a, 'b> Network<'a, 'b> { async fn get_quality_profiles(&mut self) -> Result> { info!("Fetching Radarr quality profiles"); + let event = RadarrEvent::GetQualityProfiles; let request_props = self - .radarr_request_props_from( - RadarrEvent::GetQualityProfiles.resource(), - RequestMethod::Get, - None::<()>, - ) + .request_props_from(event, RequestMethod::Get, None::<()>, None, None) .await; self @@ -1714,13 +1716,10 @@ impl<'a, 'b> Network<'a, 'b> { async fn get_queued_events(&mut self) -> Result> { info!("Fetching Radarr queued events"); + let event = RadarrEvent::GetQueuedEvents; let request_props = self - .radarr_request_props_from( - RadarrEvent::GetQueuedEvents.resource(), - RequestMethod::Get, - None::<()>, - ) + .request_props_from(event, RequestMethod::Get, None::<()>, None, None) .await; self @@ -1735,18 +1734,17 @@ impl<'a, 'b> Network<'a, 'b> { } async fn get_releases(&mut self, movie_id: Option) -> Result> { - let id = if let Some(m_id) = movie_id { - m_id - } else { - self.extract_movie_id().await - }; + let (id, movie_id_param) = self.extract_movie_id(movie_id).await; info!("Fetching releases for movie with ID: {id}"); + let event = RadarrEvent::GetReleases(None); let request_props = self - .radarr_request_props_from( - format!("{}?movieId={id}", RadarrEvent::GetReleases(None).resource()).as_str(), + .request_props_from( + event, RequestMethod::Get, None::<()>, + None, + Some(movie_id_param), ) .await; @@ -1770,13 +1768,10 @@ impl<'a, 'b> Network<'a, 'b> { async fn get_root_folders(&mut self) -> Result> { info!("Fetching Radarr root folders"); + let event = RadarrEvent::GetRootFolders; let request_props = self - .radarr_request_props_from( - RadarrEvent::GetRootFolders.resource(), - RequestMethod::Get, - None::<()>, - ) + .request_props_from(event, RequestMethod::Get, None::<()>, None, None) .await; self @@ -1788,13 +1783,10 @@ impl<'a, 'b> Network<'a, 'b> { async fn get_security_config(&mut self) -> Result { info!("Fetching Radarr security config"); + let event = RadarrEvent::GetSecurityConfig; let request_props = self - .radarr_request_props_from( - RadarrEvent::GetSecurityConfig.resource(), - RequestMethod::Get, - None::<()>, - ) + .request_props_from(event, RequestMethod::Get, None::<()>, None, None) .await; self @@ -1802,15 +1794,12 @@ impl<'a, 'b> Network<'a, 'b> { .await } - async fn get_status(&mut self) -> Result { + async fn get_radarr_status(&mut self) -> Result { info!("Fetching Radarr system status"); + let event = RadarrEvent::GetStatus; let request_props = self - .radarr_request_props_from( - RadarrEvent::GetStatus.resource(), - RequestMethod::Get, - None::<()>, - ) + .request_props_from(event, RequestMethod::Get, None::<()>, None, None) .await; self @@ -1823,13 +1812,10 @@ impl<'a, 'b> Network<'a, 'b> { async fn get_tags(&mut self) -> Result> { info!("Fetching Radarr tags"); + let event = RadarrEvent::GetTags; let request_props = self - .radarr_request_props_from( - RadarrEvent::GetTags.resource(), - RequestMethod::Get, - None::<()>, - ) + .request_props_from(event, RequestMethod::Get, None::<()>, None, None) .await; self @@ -1844,13 +1830,10 @@ impl<'a, 'b> Network<'a, 'b> { async fn get_tasks(&mut self) -> Result> { info!("Fetching Radarr tasks"); + let event = RadarrEvent::GetTasks; let request_props = self - .radarr_request_props_from( - RadarrEvent::GetTasks.resource(), - RequestMethod::Get, - None::<()>, - ) + .request_props_from(event, RequestMethod::Get, None::<()>, None, None) .await; self @@ -1862,13 +1845,10 @@ impl<'a, 'b> Network<'a, 'b> { async fn get_updates(&mut self) -> Result> { info!("Fetching Radarr updates"); + let event = RadarrEvent::GetUpdates; let request_props = self - .radarr_request_props_from( - RadarrEvent::GetUpdates.resource(), - RequestMethod::Get, - None::<()>, - ) + .request_props_from(event, RequestMethod::Get, None::<()>, None, None) .await; self @@ -1943,6 +1923,7 @@ impl<'a, 'b> Network<'a, 'b> { async fn search_movie(&mut self, query: Option) -> Result> { info!("Searching for specific Radarr movie"); + let event = RadarrEvent::SearchNewMovie(None); let search = if let Some(search_query) = query { Ok(search_query.into()) } else { @@ -1960,15 +1941,12 @@ impl<'a, 'b> Network<'a, 'b> { match search { Ok(search_string) => { let request_props = self - .radarr_request_props_from( - format!( - "{}?term={}", - RadarrEvent::SearchNewMovie(None).resource(), - encode(&search_string.text) - ) - .as_str(), + .request_props_from( + event, RequestMethod::Get, None::<()>, + None, + Some(format!("term={}", encode(&search_string.text))), ) .await; @@ -2002,6 +1980,7 @@ impl<'a, 'b> Network<'a, 'b> { } async fn start_task(&mut self, task: Option) -> Result { + let event = RadarrEvent::StartTask(None); let task_name = if let Some(t_name) = task { t_name } else { @@ -2022,11 +2001,7 @@ impl<'a, 'b> Network<'a, 'b> { let body = CommandBody { name: task_name }; let request_props = self - .radarr_request_props_from( - RadarrEvent::StartTask(None).resource(), - RequestMethod::Post, - Some(body), - ) + .request_props_from(event, RequestMethod::Post, Some(body), None, None) .await; self @@ -2035,6 +2010,8 @@ impl<'a, 'b> Network<'a, 'b> { } async fn test_indexer(&mut self, indexer_id: Option) -> Result { + let detail_event = RadarrEvent::GetIndexers; + let event = RadarrEvent::TestIndexer(None); let id = if let Some(i_id) = indexer_id { i_id } else { @@ -2053,10 +2030,12 @@ impl<'a, 'b> Network<'a, 'b> { info!("Fetching indexer details for indexer with ID: {id}"); let request_props = self - .radarr_request_props_from( - format!("{}/{id}", RadarrEvent::GetIndexers.resource()).as_str(), + .request_props_from( + detail_event, RequestMethod::Get, None::<()>, + Some(format!("/{id}")), + None, ) .await; @@ -2071,11 +2050,7 @@ impl<'a, 'b> Network<'a, 'b> { info!("Testing indexer"); let mut request_props = self - .radarr_request_props_from( - RadarrEvent::TestIndexer(None).resource(), - RequestMethod::Post, - Some(test_body), - ) + .request_props_from(event, RequestMethod::Post, Some(test_body), None, None) .await; request_props.ignore_status_code = true; @@ -2095,13 +2070,10 @@ impl<'a, 'b> Network<'a, 'b> { async fn test_all_indexers(&mut self) -> Result> { info!("Testing all indexers"); + let event = RadarrEvent::TestAllIndexers; let mut request_props = self - .radarr_request_props_from( - RadarrEvent::TestAllIndexers.resource(), - RequestMethod::Post, - None, - ) + .request_props_from(event, RequestMethod::Post, None, None, None) .await; request_props.ignore_status_code = true; @@ -2144,11 +2116,8 @@ impl<'a, 'b> Network<'a, 'b> { } async fn trigger_automatic_search(&mut self, movie_id: Option) -> Result { - let id = if let Some(m_id) = movie_id { - m_id - } else { - self.extract_movie_id().await - }; + let event = RadarrEvent::TriggerAutomaticSearch(None); + let (id, _) = self.extract_movie_id(movie_id).await; info!("Searching indexers for movie with ID: {id}"); let body = MovieCommandBody { name: "MoviesSearch".to_owned(), @@ -2156,11 +2125,7 @@ impl<'a, 'b> Network<'a, 'b> { }; let request_props = self - .radarr_request_props_from( - RadarrEvent::TriggerAutomaticSearch(None).resource(), - RequestMethod::Post, - Some(body), - ) + .request_props_from(event, RequestMethod::Post, Some(body), None, None) .await; self @@ -2170,17 +2135,14 @@ impl<'a, 'b> Network<'a, 'b> { async fn update_all_movies(&mut self) -> Result { info!("Updating all movies"); + let event = RadarrEvent::UpdateAllMovies; let body = MovieCommandBody { name: "RefreshMovie".to_owned(), movie_ids: Vec::new(), }; let request_props = self - .radarr_request_props_from( - RadarrEvent::UpdateAllMovies.resource(), - RequestMethod::Post, - Some(body), - ) + .request_props_from(event, RequestMethod::Post, Some(body), None, None) .await; self @@ -2189,11 +2151,8 @@ impl<'a, 'b> Network<'a, 'b> { } async fn update_and_scan(&mut self, movie_id: Option) -> Result { - let id = if let Some(m_id) = movie_id { - m_id - } else { - self.extract_movie_id().await - }; + let (id, _) = self.extract_movie_id(movie_id).await; + let event = RadarrEvent::UpdateAndScan(None); info!("Updating and scanning movie with ID: {id}"); let body = MovieCommandBody { name: "RefreshMovie".to_owned(), @@ -2201,11 +2160,7 @@ impl<'a, 'b> Network<'a, 'b> { }; let request_props = self - .radarr_request_props_from( - RadarrEvent::UpdateAndScan(None).resource(), - RequestMethod::Post, - Some(body), - ) + .request_props_from(event, RequestMethod::Post, Some(body), None, None) .await; self @@ -2215,16 +2170,13 @@ impl<'a, 'b> Network<'a, 'b> { async fn update_collections(&mut self) -> Result { info!("Updating collections"); + let event = RadarrEvent::UpdateCollections; let body = CommandBody { name: "RefreshCollections".to_owned(), }; let request_props = self - .radarr_request_props_from( - RadarrEvent::UpdateCollections.resource(), - RequestMethod::Post, - Some(body), - ) + .request_props_from(event, RequestMethod::Post, Some(body), None, None) .await; self @@ -2234,16 +2186,13 @@ impl<'a, 'b> Network<'a, 'b> { async fn update_downloads(&mut self) -> Result { info!("Updating downloads"); + let event = RadarrEvent::UpdateDownloads; let body = CommandBody { name: "RefreshMonitoredDownloads".to_owned(), }; let request_props = self - .radarr_request_props_from( - RadarrEvent::UpdateDownloads.resource(), - RequestMethod::Post, - Some(body), - ) + .request_props_from(event, RequestMethod::Post, Some(body), None, None) .await; self @@ -2251,44 +2200,6 @@ impl<'a, 'b> Network<'a, 'b> { .await } - async fn radarr_request_props_from( - &self, - resource: &str, - method: RequestMethod, - body: Option, - ) -> RequestProps { - let app = self.app.lock().await; - let RadarrConfig { - host, - port, - uri, - api_token, - ssl_cert_path, - } = &app.config.radarr; - let uri = if let Some(radarr_uri) = uri { - format!("{radarr_uri}/api/v3{resource}") - } else { - let protocol = if ssl_cert_path.is_some() { - "https" - } else { - "http" - }; - let host = host.as_ref().unwrap(); - format!( - "{protocol}://{host}:{}/api/v3{resource}", - port.unwrap_or(7878) - ) - }; - - RequestProps { - uri, - method, - body, - api_token: api_token.to_owned(), - ignore_status_code: false, - } - } - async fn extract_and_add_tag_ids_vec(&mut self, edit_tags: String) -> Vec { let tags_map = self.app.lock().await.data.radarr_data.tags_map.clone(); let tags = edit_tags.clone(); @@ -2319,16 +2230,21 @@ impl<'a, 'b> Network<'a, 'b> { .collect() } - async fn extract_movie_id(&mut self) -> i64 { - self - .app - .lock() - .await - .data - .radarr_data - .movies - .current_selection() - .id + async fn extract_movie_id(&mut self, movie_id: Option) -> (i64, String) { + let movie_id = if let Some(id) = movie_id { + id + } else { + self + .app + .lock() + .await + .data + .radarr_data + .movies + .current_selection() + .id + }; + (movie_id, format!("movieId={movie_id}")) } async fn extract_collection_id(&mut self) -> i64 { @@ -2342,15 +2258,6 @@ impl<'a, 'b> Network<'a, 'b> { .current_selection() .id } - - async fn append_movie_id_param(&mut self, resource: &str, movie_id: Option) -> String { - let movie_id = if let Some(id) = movie_id { - id - } else { - self.extract_movie_id().await - }; - format!("{resource}?movieId={movie_id}") - } } fn get_movie_status(has_file: bool, downloads_vec: &[DownloadRecord], movie_id: i64) -> String { diff --git a/src/network/radarr_network_tests.rs b/src/network/radarr_network_tests.rs index 67500b6..d1dfff6 100644 --- a/src/network/radarr_network_tests.rs +++ b/src/network/radarr_network_tests.rs @@ -4,7 +4,7 @@ mod test { use bimap::BiMap; use chrono::{DateTime, Utc}; - use mockito::{Matcher, Mock, Server, ServerGuard}; + use mockito::{Matcher, Server}; use pretty_assertions::{assert_eq, assert_str_eq}; use reqwest::Client; use rstest::rstest; @@ -13,6 +13,7 @@ mod test { use tokio::sync::Mutex; use tokio_util::sync::CancellationToken; + use crate::app::ServarrConfig; use crate::models::radarr_models::{ BlocklistItem, BlocklistItemMovie, CollectionMovie, IndexerField, Language, MediaInfo, MinimumAvailability, Monitor, MovieCollection, MovieFile, Quality, QualityWrapper, Rating, @@ -21,6 +22,7 @@ mod test { use crate::models::servarr_data::radarr::radarr_data::ActiveRadarrBlock; use crate::models::stateful_table::SortOption; use crate::models::HorizontallyScrollableText; + use crate::network::network_tests::test_utils::mock_servarr_api; use crate::App; use super::super::*; @@ -228,13 +230,15 @@ mod test { } #[tokio::test] - async fn test_handle_get_healthcheck_event() { - let (async_server, app_arc, _server) = mock_radarr_api( + async fn test_handle_get_radarr_healthcheck_event() { + let (async_server, app_arc, _server) = mock_servarr_api( RequestMethod::Get, None, None, None, - RadarrEvent::HealthCheck.resource(), + RadarrEvent::HealthCheck, + None, + None, ) .await; let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); @@ -246,7 +250,7 @@ mod test { #[tokio::test] async fn test_handle_get_diskspace_event() { - let (async_server, app_arc, _server) = mock_radarr_api( + let (async_server, app_arc, _server) = mock_servarr_api( RequestMethod::Get, None, Some(json!([ @@ -260,7 +264,9 @@ mod test { } ])), None, - RadarrEvent::GetOverview.resource(), + RadarrEvent::GetOverview, + None, + None, ) .await; let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); @@ -291,7 +297,7 @@ mod test { #[tokio::test] async fn test_handle_get_status_event() { - let (async_server, app_arc, _server) = mock_radarr_api( + let (async_server, app_arc, _server) = mock_servarr_api( RequestMethod::Get, None, Some(json!({ @@ -299,7 +305,9 @@ mod test { "startTime": "2023-02-25T20:16:43Z" })), None, - RadarrEvent::GetStatus.resource(), + RadarrEvent::GetStatus, + None, + None, ) .await; let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); @@ -357,12 +365,14 @@ mod test { ..movie() }, ]; - let (async_server, app_arc, _server) = mock_radarr_api( + let (async_server, app_arc, _server) = mock_servarr_api( RequestMethod::Get, None, Some(json!([movie_1, movie_2])), None, - RadarrEvent::GetMovies.resource(), + RadarrEvent::GetMovies, + None, + None, ) .await; app_arc.lock().await.data.radarr_data.movies.sort_asc = true; @@ -411,12 +421,14 @@ mod test { *movie_1.get_mut("title").unwrap() = json!("z test"); *movie_2.get_mut("id").unwrap() = json!(2); *movie_2.get_mut("title").unwrap() = json!("A test"); - let (async_server, app_arc, _server) = mock_radarr_api( + let (async_server, app_arc, _server) = mock_servarr_api( RequestMethod::Get, None, Some(json!([movie_1, movie_2])), None, - RadarrEvent::GetMovies.resource(), + RadarrEvent::GetMovies, + None, + None, ) .await; app_arc @@ -477,13 +489,14 @@ mod test { "languages": [ { "name": "English" } ], "quality": { "quality": { "name": "HD - 1080p" }} }]); - let resource = format!("{}?movieId=1", RadarrEvent::GetReleases(None).resource()); - let (async_server, app_arc, _server) = mock_radarr_api( + let (async_server, app_arc, _server) = mock_servarr_api( RequestMethod::Get, None, Some(release_json), None, - &resource, + RadarrEvent::GetReleases(None), + None, + Some("movieId=1"), ) .await; app_arc @@ -536,13 +549,14 @@ mod test { "languages": [ { "name": "English" } ], "quality": { "quality": { "name": "HD - 1080p" }} }]); - let resource = format!("{}?movieId=1", RadarrEvent::GetReleases(None).resource()); - let (async_server, app_arc, _server) = mock_radarr_api( + let (async_server, app_arc, _server) = mock_servarr_api( RequestMethod::Get, None, Some(release_json), None, - &resource, + RadarrEvent::GetReleases(None), + None, + Some("movieId=1"), ) .await; app_arc @@ -598,16 +612,14 @@ mod test { } } }]); - let resource = format!( - "{}?term=test%20term", - RadarrEvent::SearchNewMovie(None).resource() - ); - let (async_server, app_arc, _server) = mock_radarr_api( + let (async_server, app_arc, _server) = mock_servarr_api( RequestMethod::Get, None, Some(add_movie_search_result_json), None, - &resource, + RadarrEvent::SearchNewMovie(None), + None, + Some("term=test%20term"), ) .await; app_arc.lock().await.data.radarr_data.add_movie_search = Some("test term".into()); @@ -665,16 +677,14 @@ mod test { } } }]); - let resource = format!( - "{}?term=test%20term", - RadarrEvent::SearchNewMovie(None).resource() - ); - let (async_server, app_arc, _server) = mock_radarr_api( + let (async_server, app_arc, _server) = mock_servarr_api( RequestMethod::Get, None, Some(add_movie_search_result_json), None, - &resource, + RadarrEvent::SearchNewMovie(None), + None, + Some("term=test%20term"), ) .await; let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); @@ -692,14 +702,16 @@ mod test { #[tokio::test] async fn test_handle_start_task_event() { let response = json!({ "test": "test"}); - let (async_server, app_arc, _server) = mock_radarr_api( + let (async_server, app_arc, _server) = mock_servarr_api( RequestMethod::Post, Some(json!({ "name": "ApplicationCheckUpdate" })), Some(response.clone()), None, - RadarrEvent::StartTask(None).resource(), + RadarrEvent::StartTask(None), + None, + None, ) .await; app_arc @@ -727,14 +739,16 @@ mod test { #[tokio::test] async fn test_handle_start_task_event_uses_provided_task_name() { let response = json!({ "test": "test"}); - let (async_server, app_arc, _server) = mock_radarr_api( + let (async_server, app_arc, _server) = mock_servarr_api( RequestMethod::Post, Some(json!({ "name": "ApplicationCheckUpdate" })), Some(response.clone()), None, - RadarrEvent::StartTask(None).resource(), + RadarrEvent::StartTask(None), + None, + None, ) .await; let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); @@ -751,12 +765,16 @@ mod test { #[tokio::test] async fn test_handle_search_new_movie_event_no_results() { - let resource = format!( - "{}?term=test%20term", - RadarrEvent::SearchNewMovie(None).resource() - ); - let (async_server, app_arc, _server) = - mock_radarr_api(RequestMethod::Get, None, Some(json!([])), None, &resource).await; + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(json!([])), + None, + RadarrEvent::SearchNewMovie(None), + None, + Some("term=test%20term"), + ) + .await; app_arc.lock().await.data.radarr_data.add_movie_search = Some("test term".into()); let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); @@ -801,11 +819,11 @@ mod test { .unwrap(), ); let mut app = App::default(); - let radarr_config = RadarrConfig { + let radarr_config = ServarrConfig { host, port, api_token: "test1234".to_owned(), - ..RadarrConfig::default() + ..ServarrConfig::default() }; app.config.radarr = radarr_config; let app_arc = Arc::new(Mutex::new(app)); @@ -861,13 +879,14 @@ mod test { "errorMessage": "test failure", "severity": "error" }]); - let resource = format!("{}/1", RadarrEvent::GetIndexers.resource()); - let (async_details_server, app_arc, mut server) = mock_radarr_api( + let (async_details_server, app_arc, mut server) = mock_servarr_api( RequestMethod::Get, None, Some(indexer_details_json.clone()), None, - &resource, + RadarrEvent::GetIndexers, + Some("/1"), + None, ) .await; let async_test_server = server @@ -929,13 +948,14 @@ mod test { "tags": [1], "id": 1 }); - let resource = format!("{}/1", RadarrEvent::GetIndexers.resource()); - let (async_details_server, app_arc, mut server) = mock_radarr_api( + let (async_details_server, app_arc, mut server) = mock_servarr_api( RequestMethod::Get, None, Some(indexer_details_json.clone()), None, - &resource, + RadarrEvent::GetIndexers, + Some("/1"), + None, ) .await; let async_test_server = server @@ -997,13 +1017,14 @@ mod test { "tags": [1], "id": 1 }); - let resource = format!("{}/1", RadarrEvent::GetIndexers.resource()); - let (async_details_server, app_arc, mut server) = mock_radarr_api( + let (async_details_server, app_arc, mut server) = mock_servarr_api( RequestMethod::Get, None, Some(indexer_details_json.clone()), None, - &resource, + RadarrEvent::GetIndexers, + Some("/1"), + None, ) .await; let async_test_server = server @@ -1079,12 +1100,14 @@ mod test { ] }]); let response: Vec = serde_json::from_value(response_json.clone()).unwrap(); - let (async_server, app_arc, _server) = mock_radarr_api( + let (async_server, app_arc, _server) = mock_servarr_api( RequestMethod::Post, None, Some(response_json), Some(400), - RadarrEvent::TestAllIndexers.resource(), + RadarrEvent::TestAllIndexers, + None, + None, ) .await; app_arc @@ -1127,7 +1150,7 @@ mod test { #[tokio::test] async fn test_handle_trigger_automatic_search_event() { - let (async_server, app_arc, _server) = mock_radarr_api( + let (async_server, app_arc, _server) = mock_servarr_api( RequestMethod::Post, Some(json!({ "name": "MoviesSearch", @@ -1135,7 +1158,9 @@ mod test { })), Some(json!({})), None, - RadarrEvent::TriggerAutomaticSearch(None).resource(), + RadarrEvent::TriggerAutomaticSearch(None), + None, + None, ) .await; app_arc @@ -1157,7 +1182,7 @@ mod test { #[tokio::test] async fn test_handle_trigger_automatic_search_event_uses_provided_id() { - let (async_server, app_arc, _server) = mock_radarr_api( + let (async_server, app_arc, _server) = mock_servarr_api( RequestMethod::Post, Some(json!({ "name": "MoviesSearch", @@ -1165,7 +1190,9 @@ mod test { })), Some(json!({})), None, - RadarrEvent::TriggerAutomaticSearch(None).resource(), + RadarrEvent::TriggerAutomaticSearch(None), + None, + None, ) .await; let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); @@ -1180,7 +1207,7 @@ mod test { #[tokio::test] async fn test_handle_update_and_scan_event() { - let (async_server, app_arc, _server) = mock_radarr_api( + let (async_server, app_arc, _server) = mock_servarr_api( RequestMethod::Post, Some(json!({ "name": "RefreshMovie", @@ -1188,7 +1215,9 @@ mod test { })), Some(json!({})), None, - RadarrEvent::UpdateAndScan(None).resource(), + RadarrEvent::UpdateAndScan(None), + None, + None, ) .await; app_arc @@ -1210,7 +1239,7 @@ mod test { #[tokio::test] async fn test_handle_update_and_scan_event_uses_provied_movie_id() { - let (async_server, app_arc, _server) = mock_radarr_api( + let (async_server, app_arc, _server) = mock_servarr_api( RequestMethod::Post, Some(json!({ "name": "RefreshMovie", @@ -1218,7 +1247,9 @@ mod test { })), Some(json!({})), None, - RadarrEvent::UpdateAndScan(None).resource(), + RadarrEvent::UpdateAndScan(None), + None, + None, ) .await; let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); @@ -1233,7 +1264,7 @@ mod test { #[tokio::test] async fn test_handle_update_all_movies_event() { - let (async_server, app_arc, _server) = mock_radarr_api( + let (async_server, app_arc, _server) = mock_servarr_api( RequestMethod::Post, Some(json!({ "name": "RefreshMovie", @@ -1241,7 +1272,9 @@ mod test { })), Some(json!({})), None, - RadarrEvent::UpdateAllMovies.resource(), + RadarrEvent::UpdateAllMovies, + None, + None, ) .await; let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); @@ -1256,14 +1289,16 @@ mod test { #[tokio::test] async fn test_handle_update_downloads_event() { - let (async_server, app_arc, _server) = mock_radarr_api( + let (async_server, app_arc, _server) = mock_servarr_api( RequestMethod::Post, Some(json!({ "name": "RefreshMonitoredDownloads" })), Some(json!({})), None, - RadarrEvent::UpdateDownloads.resource(), + RadarrEvent::UpdateDownloads, + None, + None, ) .await; let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); @@ -1278,14 +1313,16 @@ mod test { #[tokio::test] async fn test_handle_update_collections_event() { - let (async_server, app_arc, _server) = mock_radarr_api( + let (async_server, app_arc, _server) = mock_servarr_api( RequestMethod::Post, Some(json!({ "name": "RefreshCollections" })), Some(json!({})), None, - RadarrEvent::UpdateCollections.resource(), + RadarrEvent::UpdateCollections, + None, + None, ) .await; let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); @@ -1300,14 +1337,15 @@ mod test { #[tokio::test] async fn test_handle_get_movie_details_event() { - let resource = format!("{}/1", RadarrEvent::GetMovieDetails(None).resource()); let response: Movie = serde_json::from_str(MOVIE_JSON).unwrap(); - let (async_server, app_arc, _server) = mock_radarr_api( + let (async_server, app_arc, _server) = mock_servarr_api( RequestMethod::Get, None, Some(serde_json::from_str(MOVIE_JSON).unwrap()), None, - &resource, + RadarrEvent::GetMovieDetails(None), + Some("/1"), + None, ) .await; app_arc @@ -1394,14 +1432,15 @@ mod test { #[tokio::test] async fn test_handle_get_movie_details_event_uses_provided_id() { - let resource = format!("{}/1", RadarrEvent::GetMovieDetails(None).resource()); let response: Movie = serde_json::from_str(MOVIE_JSON).unwrap(); - let (async_server, app_arc, _server) = mock_radarr_api( + let (async_server, app_arc, _server) = mock_servarr_api( RequestMethod::Get, None, Some(serde_json::from_str(MOVIE_JSON).unwrap()), None, - &resource, + RadarrEvent::GetMovieDetails(None), + Some("/1"), + None, ) .await; let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); @@ -1440,13 +1479,14 @@ mod test { "minimumAvailability": "released", "ratings": {} }); - let resource = format!("{}/1", RadarrEvent::GetMovieDetails(None).resource()); - let (async_server, app_arc, _server) = mock_radarr_api( + let (async_server, app_arc, _server) = mock_servarr_api( RequestMethod::Get, None, Some(movie_json_with_missing_fields), None, - &resource, + RadarrEvent::GetMovieDetails(None), + Some("/1"), + None, ) .await; app_arc @@ -1512,16 +1552,14 @@ mod test { }]); let response: Vec = serde_json::from_value(movie_history_item_json.clone()).unwrap(); - let resource = format!( - "{}?movieId=1", - RadarrEvent::GetMovieHistory(None).resource() - ); - let (async_server, app_arc, _server) = mock_radarr_api( + let (async_server, app_arc, _server) = mock_servarr_api( RequestMethod::Get, None, Some(movie_history_item_json), None, - &resource, + RadarrEvent::GetMovieHistory(None), + None, + Some("movieId=1"), ) .await; app_arc @@ -1568,16 +1606,14 @@ mod test { }]); let response: Vec = serde_json::from_value(movie_history_item_json.clone()).unwrap(); - let resource = format!( - "{}?movieId=1", - RadarrEvent::GetMovieHistory(None).resource() - ); - let (async_server, app_arc, _server) = mock_radarr_api( + let (async_server, app_arc, _server) = mock_servarr_api( RequestMethod::Get, None, Some(movie_history_item_json), None, - &resource, + RadarrEvent::GetMovieHistory(None), + None, + Some("movieId=1"), ) .await; let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); @@ -1601,16 +1637,14 @@ mod test { "date": "2022-12-30T07:37:56Z", "eventType": "grabbed" }]); - let resource = format!( - "{}?movieId=1", - RadarrEvent::GetMovieHistory(None).resource() - ); - let (async_server, app_arc, _server) = mock_radarr_api( + let (async_server, app_arc, _server) = mock_servarr_api( RequestMethod::Get, None, Some(movie_history_item_json), None, - &resource, + RadarrEvent::GetMovieHistory(None), + None, + Some("movieId=1"), ) .await; app_arc @@ -1740,12 +1774,14 @@ mod test { ..blocklist_item() }, ]; - let (async_server, app_arc, _server) = mock_radarr_api( + let (async_server, app_arc, _server) = mock_servarr_api( RequestMethod::Get, None, Some(blocklist_json), None, - RadarrEvent::GetBlocklist.resource(), + RadarrEvent::GetBlocklist, + None, + None, ) .await; app_arc.lock().await.data.radarr_data.blocklist.sort_asc = true; @@ -1861,12 +1897,14 @@ mod test { }, }, }]}); - let (async_server, app_arc, _server) = mock_radarr_api( + let (async_server, app_arc, _server) = mock_servarr_api( RequestMethod::Get, None, Some(blocklist_json), None, - RadarrEvent::GetBlocklist.resource(), + RadarrEvent::GetBlocklist, + None, + None, ) .await; app_arc.lock().await.data.radarr_data.blocklist.sort_asc = true; @@ -1983,12 +2021,14 @@ mod test { ..collection() }, ]; - let (async_server, app_arc, _server) = mock_radarr_api( + let (async_server, app_arc, _server) = mock_servarr_api( RequestMethod::Get, None, Some(collections_json), None, - RadarrEvent::GetCollections.resource(), + RadarrEvent::GetCollections, + None, + None, ) .await; app_arc.lock().await.data.radarr_data.collections.sort_asc = true; @@ -2090,12 +2130,14 @@ mod test { } }], }]); - let (async_server, app_arc, _server) = mock_radarr_api( + let (async_server, app_arc, _server) = mock_servarr_api( RequestMethod::Get, None, Some(collections_json), None, - RadarrEvent::GetCollections.resource(), + RadarrEvent::GetCollections, + None, + None, ) .await; app_arc.lock().await.data.radarr_data.collections.sort_asc = true; @@ -2156,12 +2198,14 @@ mod test { }); let response: DownloadsResponse = serde_json::from_value(downloads_response_json.clone()).unwrap(); - let (async_server, app_arc, _server) = mock_radarr_api( + let (async_server, app_arc, _server) = mock_servarr_api( RequestMethod::Get, None, Some(downloads_response_json), None, - RadarrEvent::GetDownloads.resource(), + RadarrEvent::GetDownloads, + None, + None, ) .await; let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); @@ -2194,12 +2238,14 @@ mod test { "sslCertPassword": "test" }); let response: HostConfig = serde_json::from_value(host_config_response.clone()).unwrap(); - let (async_server, app_arc, _server) = mock_radarr_api( + let (async_server, app_arc, _server) = mock_servarr_api( RequestMethod::Get, None, Some(host_config_response), None, - RadarrEvent::GetHostConfig.resource(), + RadarrEvent::GetHostConfig, + None, + None, ) .await; let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); @@ -2247,12 +2293,14 @@ mod test { "id": 1 }]); let response: Vec = serde_json::from_value(indexers_response_json.clone()).unwrap(); - let (async_server, app_arc, _server) = mock_radarr_api( + let (async_server, app_arc, _server) = mock_servarr_api( RequestMethod::Get, None, Some(indexers_response_json), None, - RadarrEvent::GetIndexers.resource(), + RadarrEvent::GetIndexers, + None, + None, ) .await; let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); @@ -2286,12 +2334,14 @@ mod test { }); let response: IndexerSettings = serde_json::from_value(indexer_settings_response_json.clone()).unwrap(); - let (async_server, app_arc, _server) = mock_radarr_api( + let (async_server, app_arc, _server) = mock_servarr_api( RequestMethod::Get, None, Some(indexer_settings_response_json), None, - RadarrEvent::GetAllIndexerSettings.resource(), + RadarrEvent::GetAllIndexerSettings, + None, + None, ) .await; let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); @@ -2323,12 +2373,14 @@ mod test { "whitelistedHardcodedSubs": "", "id": 1 }); - let (async_server, app_arc, _server) = mock_radarr_api( + let (async_server, app_arc, _server) = mock_servarr_api( RequestMethod::Get, None, Some(indexer_settings_response_json), None, - RadarrEvent::GetAllIndexerSettings.resource(), + RadarrEvent::GetAllIndexerSettings, + None, + None, ) .await; app_arc.lock().await.data.radarr_data.indexer_settings = Some(IndexerSettings::default()); @@ -2371,12 +2423,14 @@ mod test { trigger: "scheduled".to_owned(), }; - let (async_server, app_arc, _server) = mock_radarr_api( + let (async_server, app_arc, _server) = mock_servarr_api( RequestMethod::Get, None, Some(queued_events_json), None, - RadarrEvent::GetQueuedEvents.resource(), + RadarrEvent::GetQueuedEvents, + None, + None, ) .await; let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); @@ -2397,10 +2451,6 @@ mod test { #[tokio::test] async fn test_handle_get_logs_event() { - let resource = format!( - "{}?pageSize=500&sortDirection=descending&sortKey=time", - RadarrEvent::GetLogs(None).resource() - ); let expected_logs = vec![ HorizontallyScrollableText::from( "2023-05-20 21:29:16 UTC|FATAL|RadarrError|Some.Big.Bad.Exception|test exception", @@ -2432,12 +2482,14 @@ mod test { ] }); let response: LogResponse = serde_json::from_value(logs_response_json.clone()).unwrap(); - let (async_server, app_arc, _server) = mock_radarr_api( + let (async_server, app_arc, _server) = mock_servarr_api( RequestMethod::Get, None, Some(logs_response_json), None, - &resource, + RadarrEvent::GetLogs(None), + None, + Some("pageSize=500&sortDirection=descending&sortKey=time"), ) .await; let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); @@ -2467,10 +2519,6 @@ mod test { #[tokio::test] async fn test_handle_get_logs_event_uses_provided_events() { - let resource = format!( - "{}?pageSize=1000&sortDirection=descending&sortKey=time", - RadarrEvent::GetLogs(Some(1000)).resource() - ); let expected_logs = vec![ HorizontallyScrollableText::from( "2023-05-20 21:29:16 UTC|FATAL|RadarrError|Some.Big.Bad.Exception|test exception", @@ -2502,12 +2550,14 @@ mod test { ] }); let response: LogResponse = serde_json::from_value(logs_response_json.clone()).unwrap(); - let (async_server, app_arc, _server) = mock_radarr_api( + let (async_server, app_arc, _server) = mock_servarr_api( RequestMethod::Get, None, Some(logs_response_json), None, - &resource, + RadarrEvent::GetLogs(Some(1000)), + None, + Some("pageSize=1000&sortDirection=descending&sortKey=time"), ) .await; let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); @@ -2543,12 +2593,14 @@ mod test { }]); let response: Vec = serde_json::from_value(quality_profile_json.clone()).unwrap(); - let (async_server, app_arc, _server) = mock_radarr_api( + let (async_server, app_arc, _server) = mock_servarr_api( RequestMethod::Get, None, Some(quality_profile_json), None, - RadarrEvent::GetQualityProfiles.resource(), + RadarrEvent::GetQualityProfiles, + None, + None, ) .await; let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); @@ -2574,12 +2626,14 @@ mod test { "label": "usenet" }]); let response: Vec = serde_json::from_value(tags_json.clone()).unwrap(); - let (async_server, app_arc, _server) = mock_radarr_api( + let (async_server, app_arc, _server) = mock_servarr_api( RequestMethod::Get, None, Some(tags_json), None, - RadarrEvent::GetTags.resource(), + RadarrEvent::GetTags, + None, + None, ) .await; let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); @@ -2636,12 +2690,14 @@ mod test { last_duration: "00:00:00.5111547".to_owned(), }, ]; - let (async_server, app_arc, _server) = mock_radarr_api( + let (async_server, app_arc, _server) = mock_servarr_api( RequestMethod::Get, None, Some(tasks_json), None, - RadarrEvent::GetTasks.resource(), + RadarrEvent::GetTasks, + None, + None, ) .await; let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); @@ -2729,12 +2785,14 @@ mod test { * Killed bug 1 * Fixed bug 2" )); - let (async_server, app_arc, _server) = mock_radarr_api( + let (async_server, app_arc, _server) = mock_servarr_api( RequestMethod::Get, None, Some(updates_json), None, - RadarrEvent::GetUpdates.resource(), + RadarrEvent::GetUpdates, + None, + None, ) .await; let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); @@ -2757,12 +2815,14 @@ mod test { async fn test_handle_add_tag() { let tag_json = json!({ "id": 3, "label": "testing" }); let response: Tag = serde_json::from_value(tag_json.clone()).unwrap(); - let (async_server, app_arc, _server) = mock_radarr_api( + let (async_server, app_arc, _server) = mock_servarr_api( RequestMethod::Post, Some(json!({ "label": "testing" })), Some(tag_json), None, - RadarrEvent::GetTags.resource(), + RadarrEvent::GetTags, + None, + None, ) .await; app_arc.lock().await.data.radarr_data.tags_map = @@ -2789,9 +2849,16 @@ mod test { #[tokio::test] async fn test_handle_delete_tag_event() { - let resource = format!("{}/1", RadarrEvent::DeleteTag(1).resource()); - let (async_server, app_arc, _server) = - mock_radarr_api(RequestMethod::Delete, None, None, None, &resource).await; + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Delete, + None, + None, + None, + RadarrEvent::DeleteTag(1), + Some("/1"), + None, + ) + .await; let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); assert!(network @@ -2811,12 +2878,14 @@ mod test { "freeSpace": 219902325555200u64, }]); let response: Vec = serde_json::from_value(root_folder_json.clone()).unwrap(); - let (async_server, app_arc, _server) = mock_radarr_api( + let (async_server, app_arc, _server) = mock_servarr_api( RequestMethod::Get, None, Some(root_folder_json), None, - RadarrEvent::GetRootFolders.resource(), + RadarrEvent::GetRootFolders, + None, + None, ) .await; let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); @@ -2847,12 +2916,14 @@ mod test { }); let response: SecurityConfig = serde_json::from_value(security_config_response.clone()).unwrap(); - let (async_server, app_arc, _server) = mock_radarr_api( + let (async_server, app_arc, _server) = mock_servarr_api( RequestMethod::Get, None, Some(security_config_response), None, - RadarrEvent::GetSecurityConfig.resource(), + RadarrEvent::GetSecurityConfig, + None, + None, ) .await; let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); @@ -2883,16 +2954,14 @@ mod test { } ]); let response: Vec = serde_json::from_value(credits_json.clone()).unwrap(); - let resource = format!( - "{}?movieId=1", - RadarrEvent::GetMovieCredits(None).resource() - ); - let (async_server, app_arc, _server) = mock_radarr_api( + let (async_server, app_arc, _server) = mock_servarr_api( RequestMethod::Get, None, Some(credits_json), None, - &resource, + RadarrEvent::GetMovieCredits(None), + None, + Some("movieId=1"), ) .await; app_arc @@ -2936,16 +3005,14 @@ mod test { } ]); let response: Vec = serde_json::from_value(credits_json.clone()).unwrap(); - let resource = format!( - "{}?movieId=1", - RadarrEvent::GetMovieCredits(None).resource() - ); - let (async_server, app_arc, _server) = mock_radarr_api( + let (async_server, app_arc, _server) = mock_servarr_api( RequestMethod::Get, None, Some(credits_json), None, - &resource, + RadarrEvent::GetMovieCredits(None), + None, + Some("movieId=1"), ) .await; let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); @@ -2975,16 +3042,14 @@ mod test { "type": "crew", } ]); - let resource = format!( - "{}?movieId=1", - RadarrEvent::GetMovieCredits(None).resource() - ); - let (async_server, app_arc, _server) = mock_radarr_api( + let (async_server, app_arc, _server) = mock_servarr_api( RequestMethod::Get, None, Some(credits_json), None, - &resource, + RadarrEvent::GetMovieCredits(None), + None, + Some("movieId=1"), ) .await; app_arc @@ -3011,12 +3076,16 @@ mod test { #[tokio::test] async fn test_handle_delete_movie_event() { - let resource = format!( - "{}/1?deleteFiles=true&addImportExclusion=true", - RadarrEvent::DeleteMovie(None).resource() - ); - let (async_server, app_arc, _server) = - mock_radarr_api(RequestMethod::Delete, None, None, None, &resource).await; + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Delete, + None, + None, + None, + RadarrEvent::DeleteMovie(None), + Some("/1"), + Some("deleteFiles=true&addImportExclusion=true"), + ) + .await; { let mut app = app_arc.lock().await; app.data.radarr_data.movies.set_items(vec![movie()]); @@ -3037,12 +3106,16 @@ mod test { #[tokio::test] async fn test_handle_delete_movie_event_use_provided_params() { - let resource = format!( - "{}/1?deleteFiles=true&addImportExclusion=true", - RadarrEvent::DeleteMovie(None).resource() - ); - let (async_server, app_arc, _server) = - mock_radarr_api(RequestMethod::Delete, None, None, None, &resource).await; + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Delete, + None, + None, + None, + RadarrEvent::DeleteMovie(None), + Some("/1"), + Some("deleteFiles=true&addImportExclusion=true"), + ) + .await; let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); let delete_movie_params = DeleteMovieParams { id: 1, @@ -3077,12 +3150,14 @@ mod test { }, ]; let expected_request_json = json!({ "ids": [1, 2, 3]}); - let (async_server, app_arc, _server) = mock_radarr_api( + let (async_server, app_arc, _server) = mock_servarr_api( RequestMethod::Delete, Some(expected_request_json), None, None, - RadarrEvent::ClearBlocklist.resource(), + RadarrEvent::ClearBlocklist, + None, + None, ) .await; app_arc @@ -3104,9 +3179,16 @@ mod test { #[tokio::test] async fn test_handle_delete_blocklist_item_event() { - let resource = format!("{}/1", RadarrEvent::DeleteBlocklistItem(None).resource()); - let (async_server, app_arc, _server) = - mock_radarr_api(RequestMethod::Delete, None, None, None, &resource).await; + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Delete, + None, + None, + None, + RadarrEvent::DeleteBlocklistItem(None), + Some("/1"), + None, + ) + .await; app_arc .lock() .await @@ -3126,9 +3208,16 @@ mod test { #[tokio::test] async fn test_handle_delete_blocklist_item_event_uses_provided_id() { - let resource = format!("{}/1", RadarrEvent::DeleteBlocklistItem(None).resource()); - let (async_server, app_arc, _server) = - mock_radarr_api(RequestMethod::Delete, None, None, None, &resource).await; + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Delete, + None, + None, + None, + RadarrEvent::DeleteBlocklistItem(None), + Some("/1"), + None, + ) + .await; let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); assert!(network @@ -3141,9 +3230,16 @@ mod test { #[tokio::test] async fn test_handle_delete_download_event() { - let resource = format!("{}/1", RadarrEvent::DeleteDownload(None).resource()); - let (async_server, app_arc, _server) = - mock_radarr_api(RequestMethod::Delete, None, None, None, &resource).await; + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Delete, + None, + None, + None, + RadarrEvent::DeleteDownload(None), + Some("/1"), + None, + ) + .await; app_arc .lock() .await @@ -3163,9 +3259,16 @@ mod test { #[tokio::test] async fn test_handle_delete_download_event_uses_provided_id() { - let resource = format!("{}/1", RadarrEvent::DeleteDownload(None).resource()); - let (async_server, app_arc, _server) = - mock_radarr_api(RequestMethod::Delete, None, None, None, &resource).await; + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Delete, + None, + None, + None, + RadarrEvent::DeleteDownload(None), + Some("/1"), + None, + ) + .await; let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); assert!(network @@ -3178,9 +3281,16 @@ mod test { #[tokio::test] async fn test_handle_delete_indexer_event() { - let resource = format!("{}/1", RadarrEvent::DeleteIndexer(None).resource()); - let (async_server, app_arc, _server) = - mock_radarr_api(RequestMethod::Delete, None, None, None, &resource).await; + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Delete, + None, + None, + None, + RadarrEvent::DeleteIndexer(None), + Some("/1"), + None, + ) + .await; app_arc .lock() .await @@ -3200,9 +3310,16 @@ mod test { #[tokio::test] async fn test_handle_delete_indexer_event_uses_provided_id() { - let resource = format!("{}/1", RadarrEvent::DeleteIndexer(None).resource()); - let (async_server, app_arc, _server) = - mock_radarr_api(RequestMethod::Delete, None, None, None, &resource).await; + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Delete, + None, + None, + None, + RadarrEvent::DeleteIndexer(None), + Some("/1"), + None, + ) + .await; let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); assert!(network @@ -3215,9 +3332,16 @@ mod test { #[tokio::test] async fn test_handle_delete_root_folder_event() { - let resource = format!("{}/1", RadarrEvent::DeleteRootFolder(None).resource()); - let (async_server, app_arc, _server) = - mock_radarr_api(RequestMethod::Delete, None, None, None, &resource).await; + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Delete, + None, + None, + None, + RadarrEvent::DeleteRootFolder(None), + Some("/1"), + None, + ) + .await; app_arc .lock() .await @@ -3237,9 +3361,16 @@ mod test { #[tokio::test] async fn test_handle_delete_root_folder_event_uses_provided_id() { - let resource = format!("{}/1", RadarrEvent::DeleteRootFolder(None).resource()); - let (async_server, app_arc, _server) = - mock_radarr_api(RequestMethod::Delete, None, None, None, &resource).await; + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Delete, + None, + None, + None, + RadarrEvent::DeleteRootFolder(None), + Some("/1"), + None, + ) + .await; let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); assert!(network @@ -3253,7 +3384,7 @@ mod test { #[rstest] #[tokio::test] async fn test_handle_add_movie_event(#[values(true, false)] movie_details_context: bool) { - let (async_server, app_arc, _server) = mock_radarr_api( + let (async_server, app_arc, _server) = mock_servarr_api( RequestMethod::Post, Some(json!({ "tmdbId": 1234, @@ -3270,7 +3401,9 @@ mod test { })), Some(json!({})), None, - RadarrEvent::AddMovie(None).resource(), + RadarrEvent::AddMovie(None), + None, + None, ) .await; @@ -3343,7 +3476,7 @@ mod test { #[tokio::test] async fn test_handle_add_movie_event_uses_provided_body() { - let (async_server, app_arc, _server) = mock_radarr_api( + let (async_server, app_arc, _server) = mock_servarr_api( RequestMethod::Post, Some(json!({ "tmdbId": 1234, @@ -3360,7 +3493,9 @@ mod test { })), Some(json!({})), None, - RadarrEvent::AddMovie(None).resource(), + RadarrEvent::AddMovie(None), + None, + None, ) .await; let body = AddMovieBody { @@ -3396,7 +3531,7 @@ mod test { #[tokio::test] async fn test_handle_add_movie_event_reuse_existing_table_if_search_already_performed() { - let (async_server, app_arc, _server) = mock_radarr_api( + let (async_server, app_arc, _server) = mock_servarr_api( RequestMethod::Post, Some(json!({ "tmdbId": 5678, @@ -3413,7 +3548,9 @@ mod test { })), Some(json!({})), None, - RadarrEvent::AddMovie(None).resource(), + RadarrEvent::AddMovie(None), + None, + None, ) .await; @@ -3495,14 +3632,16 @@ mod test { #[tokio::test] async fn test_handle_add_root_folder_event() { - let (async_server, app_arc, _server) = mock_radarr_api( + let (async_server, app_arc, _server) = mock_servarr_api( RequestMethod::Post, Some(json!({ "path": "/nfs/test" })), Some(json!({})), None, - RadarrEvent::AddRootFolder(None).resource(), + RadarrEvent::AddRootFolder(None), + None, + None, ) .await; @@ -3526,14 +3665,16 @@ mod test { #[tokio::test] async fn test_handle_add_root_folder_event_uses_provided_path() { - let (async_server, app_arc, _server) = mock_radarr_api( + let (async_server, app_arc, _server) = mock_servarr_api( RequestMethod::Post, Some(json!({ "path": "/test/test" })), Some(json!({})), None, - RadarrEvent::AddRootFolder(None).resource(), + RadarrEvent::AddRootFolder(None), + None, + None, ) .await; @@ -3567,12 +3708,14 @@ mod test { "whitelistedHardcodedSubs": "", "id": 1 }); - let (async_server, app_arc, _server) = mock_radarr_api( + let (async_server, app_arc, _server) = mock_servarr_api( RequestMethod::Put, Some(indexer_settings_json), None, None, - RadarrEvent::EditAllIndexerSettings(None).resource(), + RadarrEvent::EditAllIndexerSettings(None), + None, + None, ) .await; @@ -3607,12 +3750,14 @@ mod test { "whitelistedHardcodedSubs": "", "id": 1 }); - let (async_server, app_arc, _server) = mock_radarr_api( + let (async_server, app_arc, _server) = mock_servarr_api( RequestMethod::Put, Some(indexer_settings_json), None, None, - RadarrEvent::EditAllIndexerSettings(None).resource(), + RadarrEvent::EditAllIndexerSettings(None), + None, + None, ) .await; @@ -3668,13 +3813,14 @@ mod test { *expected_body.get_mut("rootFolderPath").unwrap() = json!("/nfs/Test Path"); *expected_body.get_mut("searchOnAdd").unwrap() = json!(false); - let resource = format!("{}/123", RadarrEvent::GetCollections.resource()); - let (async_details_server, app_arc, mut server) = mock_radarr_api( + let (async_details_server, app_arc, mut server) = mock_servarr_api( RequestMethod::Get, None, Some(detailed_collection_body), None, - &resource, + RadarrEvent::GetCollections, + Some("/123"), + None, ) .await; let async_edit_server = server @@ -3768,13 +3914,14 @@ mod test { *expected_body.get_mut("rootFolderPath").unwrap() = json!("/nfs/Test Path"); *expected_body.get_mut("searchOnAdd").unwrap() = json!(false); - let resource = format!("{}/123", RadarrEvent::GetCollections.resource()); - let (async_details_server, app_arc, mut server) = mock_radarr_api( + let (async_details_server, app_arc, mut server) = mock_servarr_api( RequestMethod::Get, None, Some(detailed_collection_body), None, - &resource, + RadarrEvent::GetCollections, + Some("/123"), + None, ) .await; let async_edit_server = server @@ -3851,13 +3998,14 @@ mod test { *expected_body.get_mut("rootFolderPath").unwrap() = json!("/nfs/movies"); *expected_body.get_mut("searchOnAdd").unwrap() = json!(true); - let resource = format!("{}/123", RadarrEvent::GetCollections.resource()); - let (async_details_server, app_arc, mut server) = mock_radarr_api( + let (async_details_server, app_arc, mut server) = mock_servarr_api( RequestMethod::Get, None, Some(detailed_collection_body), None, - &resource, + RadarrEvent::GetCollections, + Some("/123"), + None, ) .await; let async_edit_server = server @@ -3938,13 +4086,14 @@ mod test { "id": 1 }); - let resource = format!("{}/1", RadarrEvent::GetIndexers.resource()); - let (async_details_server, app_arc, mut server) = mock_radarr_api( + let (async_details_server, app_arc, mut server) = mock_servarr_api( RequestMethod::Get, None, Some(indexer_details_json), None, - &resource, + RadarrEvent::GetIndexers, + Some("/1"), + None, ) .await; let async_edit_server = server @@ -4030,13 +4179,14 @@ mod test { "id": 1 }); - let resource = format!("{}/1", RadarrEvent::GetIndexers.resource()); - let (async_details_server, app_arc, mut server) = mock_radarr_api( + let (async_details_server, app_arc, mut server) = mock_servarr_api( RequestMethod::Get, None, Some(indexer_details_json), None, - &resource, + RadarrEvent::GetIndexers, + Some("/1"), + None, ) .await; let async_edit_server = server @@ -4138,13 +4288,14 @@ mod test { "id": 1 }); - let resource = format!("{}/1", RadarrEvent::GetIndexers.resource()); - let (async_details_server, app_arc, mut server) = mock_radarr_api( + let (async_details_server, app_arc, mut server) = mock_servarr_api( RequestMethod::Get, None, Some(indexer_details_json), None, - &resource, + RadarrEvent::GetIndexers, + Some("/1"), + None, ) .await; let async_edit_server = server @@ -4266,13 +4417,14 @@ mod test { ..EditIndexerParams::default() }; - let resource = format!("{}/1", RadarrEvent::GetIndexers.resource()); - let (async_details_server, app_arc, mut server) = mock_radarr_api( + let (async_details_server, app_arc, mut server) = mock_servarr_api( RequestMethod::Get, None, Some(indexer_details_json), None, - &resource, + RadarrEvent::GetIndexers, + Some("/1"), + None, ) .await; let async_edit_server = server @@ -4326,13 +4478,14 @@ mod test { ..EditIndexerParams::default() }; - let resource = format!("{}/1", RadarrEvent::GetIndexers.resource()); - let (async_details_server, app_arc, mut server) = mock_radarr_api( + let (async_details_server, app_arc, mut server) = mock_servarr_api( RequestMethod::Get, None, Some(indexer_details_json.clone()), None, - &resource, + RadarrEvent::GetIndexers, + Some("/1"), + None, ) .await; let async_edit_server = server @@ -4411,13 +4564,14 @@ mod test { ..EditIndexerParams::default() }; - let resource = format!("{}/1", RadarrEvent::GetIndexers.resource()); - let (async_details_server, app_arc, mut server) = mock_radarr_api( + let (async_details_server, app_arc, mut server) = mock_servarr_api( RequestMethod::Get, None, Some(indexer_details_json), None, - &resource, + RadarrEvent::GetIndexers, + Some("/1"), + None, ) .await; let async_edit_server = server @@ -4450,13 +4604,14 @@ mod test { *expected_body.get_mut("path").unwrap() = json!("/nfs/Test Path"); *expected_body.get_mut("tags").unwrap() = json!([1, 2]); - let resource = format!("{}/1", RadarrEvent::GetMovieDetails(None).resource()); - let (async_details_server, app_arc, mut server) = mock_radarr_api( + let (async_details_server, app_arc, mut server) = mock_servarr_api( RequestMethod::Get, None, Some(serde_json::from_str(MOVIE_JSON).unwrap()), None, - &resource, + RadarrEvent::GetMovieDetails(None), + Some("/1"), + None, ) .await; let async_edit_server = server @@ -4516,13 +4671,14 @@ mod test { *expected_body.get_mut("path").unwrap() = json!("/nfs/Test Path"); *expected_body.get_mut("tags").unwrap() = json!([1, 2]); - let resource = format!("{}/1", RadarrEvent::GetMovieDetails(None).resource()); - let (async_details_server, app_arc, mut server) = mock_radarr_api( + let (async_details_server, app_arc, mut server) = mock_servarr_api( RequestMethod::Get, None, Some(serde_json::from_str(MOVIE_JSON).unwrap()), None, - &resource, + RadarrEvent::GetMovieDetails(None), + Some("/1"), + None, ) .await; let async_edit_server = server @@ -4558,13 +4714,14 @@ mod test { #[tokio::test] async fn test_handle_edit_movie_event_uses_provided_parameters_defaults_to_previous_values() { let expected_body: Value = serde_json::from_str(MOVIE_JSON).unwrap(); - let resource = format!("{}/1", RadarrEvent::GetMovieDetails(None).resource()); - let (async_details_server, app_arc, mut server) = mock_radarr_api( + let (async_details_server, app_arc, mut server) = mock_servarr_api( RequestMethod::Get, None, Some(serde_json::from_str(MOVIE_JSON).unwrap()), None, - &resource, + RadarrEvent::GetMovieDetails(None), + Some("/1"), + None, ) .await; let async_edit_server = server @@ -4598,13 +4755,14 @@ mod test { let mut expected_body: Value = serde_json::from_str(MOVIE_JSON).unwrap(); *expected_body.get_mut("tags").unwrap() = json!([]); - let resource = format!("{}/1", RadarrEvent::GetMovieDetails(None).resource()); - let (async_details_server, app_arc, mut server) = mock_radarr_api( + let (async_details_server, app_arc, mut server) = mock_servarr_api( RequestMethod::Get, None, Some(serde_json::from_str(MOVIE_JSON).unwrap()), None, - &resource, + RadarrEvent::GetMovieDetails(None), + Some("/1"), + None, ) .await; let async_edit_server = server @@ -4635,7 +4793,7 @@ mod test { #[tokio::test] async fn test_handle_download_release_event() { - let (async_server, app_arc, _server) = mock_radarr_api( + let (async_server, app_arc, _server) = mock_servarr_api( RequestMethod::Post, Some(json!({ "guid": "1234", @@ -4644,7 +4802,9 @@ mod test { })), Some(json!({})), None, - RadarrEvent::DownloadRelease(None).resource(), + RadarrEvent::DownloadRelease(None), + None, + None, ) .await; let mut movie_details_modal = MovieDetailsModal::default(); @@ -4671,7 +4831,7 @@ mod test { #[tokio::test] async fn test_handle_download_release_event_uses_provided_params() { - let (async_server, app_arc, _server) = mock_radarr_api( + let (async_server, app_arc, _server) = mock_servarr_api( RequestMethod::Post, Some(json!({ "guid": "1234", @@ -4680,7 +4840,9 @@ mod test { })), Some(json!({})), None, - RadarrEvent::DownloadRelease(None).resource(), + RadarrEvent::DownloadRelease(None), + None, + None, ) .await; let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); @@ -4720,12 +4882,14 @@ mod test { #[tokio::test] async fn test_extract_and_add_tag_ids_vec_add_missing_tags_first() { - let (async_server, app_arc, _server) = mock_radarr_api( + let (async_server, app_arc, _server) = mock_servarr_api( RequestMethod::Post, Some(json!({ "label": "testing" })), Some(json!({ "id": 3, "label": "testing" })), None, - RadarrEvent::GetTags.resource(), + RadarrEvent::GetTags, + None, + None, ) .await; let tags = "usenet, test, testing".to_owned(); @@ -4769,7 +4933,31 @@ mod test { }]); let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); - assert_eq!(network.extract_movie_id().await, 1); + let (id, movie_id_param) = network.extract_movie_id(None).await; + + assert_eq!(id, 1); + assert_str_eq!(movie_id_param, "movieId=1"); + } + + #[tokio::test] + async fn test_extract_movie_id_uses_provided_id() { + let app_arc = Arc::new(Mutex::new(App::default())); + app_arc + .lock() + .await + .data + .radarr_data + .movies + .set_items(vec![Movie { + id: 1, + ..Movie::default() + }]); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + let (id, movie_id_param) = network.extract_movie_id(Some(2)).await; + + assert_eq!(id, 2); + assert_str_eq!(movie_id_param, "movieId=2"); } #[tokio::test] @@ -4783,7 +4971,10 @@ mod test { app_arc.lock().await.data.radarr_data.movies = filtered_movies; let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); - assert_eq!(network.extract_movie_id().await, 1); + let (id, movie_id_param) = network.extract_movie_id(None).await; + + assert_eq!(id, 1); + assert_str_eq!(movie_id_param, "movieId=1"); } #[tokio::test] @@ -4818,115 +5009,6 @@ mod test { assert_eq!(network.extract_collection_id().await, 1); } - #[tokio::test] - async fn test_append_movie_id_param() { - let app_arc = Arc::new(Mutex::new(App::default())); - app_arc - .lock() - .await - .data - .radarr_data - .movies - .set_items(vec![Movie { - id: 1, - ..Movie::default() - }]); - let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); - - assert_str_eq!( - network.append_movie_id_param("/test", None).await, - "/test?movieId=1" - ); - } - - #[tokio::test] - async fn test_append_movie_id_param_uses_provided_id() { - let app_arc = Arc::new(Mutex::new(App::default())); - app_arc - .lock() - .await - .data - .radarr_data - .movies - .set_items(vec![Movie { - id: 1, - ..Movie::default() - }]); - let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); - - assert_str_eq!( - network.append_movie_id_param("/test", Some(11)).await, - "/test?movieId=11" - ); - } - - #[tokio::test] - async fn test_radarr_request_props_from_default_radarr_config() { - let app_arc = Arc::new(Mutex::new(App::default())); - let network = Network::new(&app_arc, CancellationToken::new(), Client::new()); - - let request_props = network - .radarr_request_props_from("/test", RequestMethod::Get, None::<()>) - .await; - - assert_str_eq!(request_props.uri, "http://localhost:7878/api/v3/test"); - assert_eq!(request_props.method, RequestMethod::Get); - assert_eq!(request_props.body, None); - assert!(request_props.api_token.is_empty()); - - app_arc.lock().await.config.radarr = RadarrConfig { - host: Some("192.168.0.123".to_owned()), - port: Some(8080), - api_token: "testToken1234".to_owned(), - ..RadarrConfig::default() - }; - } - - #[tokio::test] - async fn test_radarr_request_props_from_custom_radarr_config() { - let api_token = "testToken1234".to_owned(); - let app_arc = Arc::new(Mutex::new(App::default())); - app_arc.lock().await.config.radarr = RadarrConfig { - host: Some("192.168.0.123".to_owned()), - port: Some(8080), - api_token: api_token.clone(), - ssl_cert_path: Some("/test/cert.crt".to_owned()), - ..RadarrConfig::default() - }; - let network = Network::new(&app_arc, CancellationToken::new(), Client::new()); - - let request_props = network - .radarr_request_props_from("/test", RequestMethod::Get, None::<()>) - .await; - - assert_str_eq!(request_props.uri, "https://192.168.0.123:8080/api/v3/test"); - assert_eq!(request_props.method, RequestMethod::Get); - assert_eq!(request_props.body, None); - assert_str_eq!(request_props.api_token, api_token); - } - - #[tokio::test] - async fn test_radarr_request_props_from_custom_radarr_config_using_uri_instead_of_host_and_port() - { - let api_token = "testToken1234".to_owned(); - let app_arc = Arc::new(Mutex::new(App::default())); - app_arc.lock().await.config.radarr = RadarrConfig { - uri: Some("https://192.168.0.123:8080".to_owned()), - api_token: api_token.clone(), - ..RadarrConfig::default() - }; - let network = Network::new(&app_arc, CancellationToken::new(), Client::new()); - - let request_props = network - .radarr_request_props_from("/test", RequestMethod::Get, None::<()>) - .await; - - assert_str_eq!(request_props.uri, "https://192.168.0.123:8080/api/v3/test"); - assert_eq!(request_props.method, RequestMethod::Get); - assert_eq!(request_props.body, None); - assert_str_eq!(request_props.api_token, api_token); - } - #[test] fn test_get_movie_status_downloaded() { assert_str_eq!(get_movie_status(true, &[], 0), "Downloaded"); @@ -4979,52 +5061,6 @@ mod test { ); } - async fn mock_radarr_api( - method: RequestMethod, - request_body: Option, - response_body: Option, - response_status: Option, - resource: &str, - ) -> (Mock, Arc>>, ServerGuard) { - let status = response_status.unwrap_or(200); - let mut server = Server::new_async().await; - let mut async_server = server - .mock( - &method.to_string().to_uppercase(), - format!("/api/v3{resource}").as_str(), - ) - .match_header("X-Api-Key", "test1234") - .with_status(status); - - if let Some(body) = request_body { - async_server = async_server.match_body(Matcher::Json(body)); - } - - if let Some(body) = response_body { - async_server = async_server.with_body(body.to_string()); - } - - async_server = async_server.create_async().await; - - let host = Some(server.host_with_port().split(':').collect::>()[0].to_owned()); - let port = Some( - server.host_with_port().split(':').collect::>()[1] - .parse() - .unwrap(), - ); - let mut app = App::default(); - let radarr_config = RadarrConfig { - host, - port, - api_token: "test1234".to_owned(), - ..RadarrConfig::default() - }; - app.config.radarr = radarr_config; - let app_arc = Arc::new(Mutex::new(app)); - - (async_server, app_arc, server) - } - fn language() -> Language { Language { name: "English".to_owned(), diff --git a/src/network/sonarr_network.rs b/src/network/sonarr_network.rs new file mode 100644 index 0000000..f155763 --- /dev/null +++ b/src/network/sonarr_network.rs @@ -0,0 +1,106 @@ +use anyhow::Result; +use log::info; + +use crate::{ + models::{ + servarr_data::sonarr::sonarr_data::ActiveSonarrBlock, + sonarr_models::{Series, SonarrSerdeable, SystemStatus}, + Route, + }, + network::RequestMethod, +}; + +use super::{Network, NetworkEvent, NetworkResource}; +#[cfg(test)] +#[path = "sonarr_network_tests.rs"] +mod sonarr_network_tests; + +#[derive(Debug, Eq, PartialEq, Clone)] +pub enum SonarrEvent { + GetStatus, + HealthCheck, + ListSeries, +} + +impl NetworkResource for SonarrEvent { + fn resource(&self) -> &'static str { + match &self { + SonarrEvent::GetStatus => "/system/status", + SonarrEvent::HealthCheck => "/health", + SonarrEvent::ListSeries => "/series", + } + } +} + +impl From for NetworkEvent { + fn from(sonarr_event: SonarrEvent) -> Self { + NetworkEvent::Sonarr(sonarr_event) + } +} + +impl<'a, 'b> Network<'a, 'b> { + pub async fn handle_sonarr_event( + &mut self, + sonarr_event: SonarrEvent, + ) -> Result { + match sonarr_event { + SonarrEvent::GetStatus => self.get_sonarr_status().await.map(SonarrSerdeable::from), + SonarrEvent::HealthCheck => self + .get_sonarr_healthcheck() + .await + .map(SonarrSerdeable::from), + SonarrEvent::ListSeries => self.list_series().await.map(SonarrSerdeable::from), + } + } + + async fn get_sonarr_healthcheck(&mut self) -> Result<()> { + info!("Performing Sonarr health check"); + let event = SonarrEvent::HealthCheck; + + let request_props = self + .request_props_from(event, RequestMethod::Get, None::<()>, None, None) + .await; + + self + .handle_request::<(), ()>(request_props, |_, _| ()) + .await + } + + async fn list_series(&mut self) -> Result> { + info!("Fetching Sonarr library"); + let event = SonarrEvent::ListSeries; + + let request_props = self + .request_props_from(event, RequestMethod::Get, None::<()>, None, None) + .await; + + self + .handle_request::<(), Vec>(request_props, |mut series_vec, mut app| { + if !matches!( + app.get_current_route(), + Route::Sonarr(ActiveSonarrBlock::SeriesSortPrompt, _) + ) { + series_vec.sort_by(|a, b| a.id.cmp(&b.id)); + app.data.sonarr_data.series.set_items(series_vec); + app.data.sonarr_data.series.apply_sorting_toggle(false); + } + }) + .await + } + + async fn get_sonarr_status(&mut self) -> Result { + info!("Fetching Sonarr system status"); + let event = SonarrEvent::GetStatus; + + let request_props = self + .request_props_from(event, RequestMethod::Get, None::<()>, None, None) + .await; + + self + .handle_request::<(), SystemStatus>(request_props, |system_status, mut app| { + app.data.sonarr_data.version = system_status.version; + app.data.sonarr_data.start_time = system_status.start_time; + }) + .await + } +} diff --git a/src/network/sonarr_network_tests.rs b/src/network/sonarr_network_tests.rs new file mode 100644 index 0000000..0de2bb0 --- /dev/null +++ b/src/network/sonarr_network_tests.rs @@ -0,0 +1,351 @@ +#[cfg(test)] +mod test { + use chrono::{DateTime, Utc}; + use pretty_assertions::{assert_eq, assert_str_eq}; + use reqwest::Client; + use rstest::rstest; + use serde_json::json; + use serde_json::{Number, Value}; + 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::SonarrSerdeable, stateful_table::SortOption}; + + use crate::{ + models::sonarr_models::{ + Rating, Season, SeasonStatistics, Series, SeriesStatistics, SeriesStatus, SeriesType, + }, + network::{ + network_tests::test_utils::mock_servarr_api, sonarr_network::SonarrEvent, Network, + NetworkEvent, NetworkResource, RequestMethod, + }, + }; + + const SERIES_JSON: &str = r#"{ + "title": "Test", + "status": "continuing", + "ended": false, + "overview": "Blah blah blah", + "network": "HBO", + "seasons": [ + { + "seasonNumber": 1, + "monitored": true, + "statistics": { + "previousAiring": "2022-10-24T01:00:00Z", + "episodeFileCount": 10, + "episodeCount": 10, + "totalEpisodeCount": 10, + "sizeOnDisk": 36708563419, + "percentOfEpisodes": 100.0 + } + } + ], + "year": 2022, + "path": "/nfs/tv/Test", + "qualityProfileId": 6, + "languageProfileId": 1, + "seasonFolder": true, + "monitored": true, + "runtime": 63, + "tvdbId": 371572, + "seriesType": "standard", + "certification": "TV-MA", + "genres": ["cool", "family", "fun"], + "tags": [3], + "ratings": {"votes": 406744, "value": 8.4}, + "statistics": { + "seasonCount": 2, + "episodeFileCount": 18, + "episodeCount": 18, + "totalEpisodeCount": 50, + "sizeOnDisk": 63894022699, + "percentOfEpisodes": 100.0 + }, + "id": 1 + } +"#; + + #[rstest] + fn test_resource_series(#[values(SonarrEvent::ListSeries)] event: SonarrEvent) { + assert_str_eq!(event.resource(), "/series"); + } + + #[rstest] + #[case(SonarrEvent::HealthCheck, "/health")] + #[case(SonarrEvent::GetStatus, "/system/status")] + fn test_resource(#[case] event: SonarrEvent, #[case] expected_uri: String) { + assert_str_eq!(event.resource(), expected_uri); + } + + #[test] + fn test_from_sonarr_event() { + assert_eq!( + NetworkEvent::Sonarr(SonarrEvent::HealthCheck), + NetworkEvent::from(SonarrEvent::HealthCheck) + ); + } + + #[tokio::test] + async fn test_handle_get_sonarr_healthcheck_event() { + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Get, + None, + None, + None, + SonarrEvent::HealthCheck, + None, + None, + ) + .await; + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + let _ = network.handle_sonarr_event(SonarrEvent::HealthCheck).await; + + async_server.assert_async().await; + } + + #[rstest] + #[tokio::test] + async fn test_handle_get_series_event(#[values(true, false)] use_custom_sorting: bool) { + let mut series_1: Value = serde_json::from_str(SERIES_JSON).unwrap(); + let mut series_2: Value = serde_json::from_str(SERIES_JSON).unwrap(); + *series_1.get_mut("id").unwrap() = json!(1); + *series_1.get_mut("title").unwrap() = json!("z test"); + *series_2.get_mut("id").unwrap() = json!(2); + *series_2.get_mut("title").unwrap() = json!("A test"); + let expected_series = vec![ + Series { + id: 1, + title: "z test".into(), + ..series() + }, + Series { + id: 2, + title: "A test".into(), + ..series() + }, + ]; + let mut expected_sorted_series = vec![ + Series { + id: 1, + title: "z test".into(), + ..series() + }, + Series { + id: 2, + title: "A test".into(), + ..series() + }, + ]; + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(json!([series_1, series_2])), + None, + SonarrEvent::ListSeries, + None, + None, + ) + .await; + app_arc.lock().await.data.sonarr_data.series.sort_asc = true; + if use_custom_sorting { + let cmp_fn = |a: &Series, b: &Series| { + a.title + .text + .to_lowercase() + .cmp(&b.title.text.to_lowercase()) + }; + expected_sorted_series.sort_by(cmp_fn); + let title_sort_option = SortOption { + name: "Title", + cmp_fn: Some(cmp_fn), + }; + app_arc + .lock() + .await + .data + .sonarr_data + .series + .sorting(vec![title_sort_option]); + } + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + if let SonarrSerdeable::SeriesVec(series) = network + .handle_sonarr_event(SonarrEvent::ListSeries) + .await + .unwrap() + { + async_server.assert_async().await; + assert_eq!( + app_arc.lock().await.data.sonarr_data.series.items, + expected_sorted_series + ); + assert!(app_arc.lock().await.data.sonarr_data.series.sort_asc); + assert_eq!(series, expected_series); + } + } + + #[tokio::test] + async fn test_handle_get_series_event_no_op_while_user_is_selecting_sort_options() { + let mut series_1: Value = serde_json::from_str(SERIES_JSON).unwrap(); + let mut series_2: Value = serde_json::from_str(SERIES_JSON).unwrap(); + *series_1.get_mut("id").unwrap() = json!(1); + *series_1.get_mut("title").unwrap() = json!("z test"); + *series_2.get_mut("id").unwrap() = json!(2); + *series_2.get_mut("title").unwrap() = json!("A test"); + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(json!([series_1, series_2])), + None, + SonarrEvent::ListSeries, + None, + None, + ) + .await; + app_arc + .lock() + .await + .push_navigation_stack(ActiveSonarrBlock::SeriesSortPrompt.into()); + app_arc.lock().await.data.sonarr_data.series.sort_asc = true; + let cmp_fn = |a: &Series, b: &Series| { + a.title + .text + .to_lowercase() + .cmp(&b.title.text.to_lowercase()) + }; + let title_sort_option = SortOption { + name: "Title", + cmp_fn: Some(cmp_fn), + }; + app_arc + .lock() + .await + .data + .sonarr_data + .series + .sorting(vec![title_sort_option]); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + assert!(network + .handle_sonarr_event(SonarrEvent::ListSeries) + .await + .is_ok()); + + async_server.assert_async().await; + assert!(app_arc + .lock() + .await + .data + .sonarr_data + .series + .items + .is_empty()); + assert!(app_arc.lock().await.data.sonarr_data.series.sort_asc); + } + + #[tokio::test] + async fn test_handle_get_status_event() { + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(json!({ + "version": "v1", + "startTime": "2023-02-25T20:16:43Z" + })), + None, + SonarrEvent::GetStatus, + None, + None, + ) + .await; + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + let date_time = DateTime::from(DateTime::parse_from_rfc3339("2023-02-25T20:16:43Z").unwrap()) + as DateTime; + + if let SonarrSerdeable::SystemStatus(status) = network + .handle_sonarr_event(SonarrEvent::GetStatus) + .await + .unwrap() + { + async_server.assert_async().await; + assert_str_eq!(app_arc.lock().await.data.sonarr_data.version, "v1"); + assert_eq!(app_arc.lock().await.data.sonarr_data.start_time, date_time); + assert_eq!( + status, + SystemStatus { + version: "v1".to_owned(), + start_time: date_time + } + ); + } + } + + fn rating() -> Rating { + Rating { + votes: 406744, + value: 8.4, + } + } + + fn season() -> Season { + Season { + season_number: 1, + monitored: true, + statistics: season_statistics(), + } + } + + fn season_statistics() -> SeasonStatistics { + SeasonStatistics { + previous_airing: Some(DateTime::from( + DateTime::parse_from_rfc3339("2022-10-24T01:00:00Z").unwrap(), + )), + next_airing: None, + episode_file_count: 10, + episode_count: 10, + total_episode_count: 10, + size_on_disk: 36708563419, + percent_of_episodes: 100.0, + } + } + + fn series() -> Series { + Series { + title: "Test".to_owned().into(), + status: SeriesStatus::Continuing, + ended: false, + overview: "Blah blah blah".into(), + network: Some("HBO".to_owned()), + seasons: Some(vec![season()]), + year: 2022, + path: "/nfs/tv/Test".to_owned(), + quality_profile_id: 6, + language_profile_id: 1, + season_folder: true, + monitored: true, + runtime: 63, + tvdb_id: 371572, + series_type: SeriesType::Standard, + certification: Some("TV-MA".to_owned()), + genres: vec!["cool".to_owned(), "family".to_owned(), "fun".to_owned()], + tags: vec![Number::from(3)], + ratings: rating(), + statistics: Some(series_statistics()), + id: 1, + } + } + + fn series_statistics() -> SeriesStatistics { + SeriesStatistics { + season_count: 2, + episode_file_count: 18, + episode_count: 18, + total_episode_count: 50, + size_on_disk: 63894022699, + percent_of_episodes: 100.0, + } + } +} From 1ca9265a2a061bd70c4f0a89acf504ce96fcc0a6 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Mon, 11 Nov 2024 13:45:32 -0700 Subject: [PATCH 002/119] 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, From a8f3bed402720ffd6fcf1fa06f934ecfc1f010cf Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Mon, 11 Nov 2024 14:00:07 -0700 Subject: [PATCH 003/119] feat(sonarr): Added the ability to fetch Sonarr logs --- src/models/servarr_data/sonarr/sonarr_data.rs | 5 +- .../servarr_data/sonarr/sonarr_data_tests.rs | 1 + src/models/sonarr_models.rs | 19 +++ src/models/sonarr_models_tests.rs | 18 ++- src/network/radarr_network.rs | 7 +- src/network/radarr_network_tests.rs | 4 +- src/network/sonarr_network.rs | 58 ++++++- src/network/sonarr_network_tests.rs | 142 +++++++++++++++++- 8 files changed, 242 insertions(+), 12 deletions(-) diff --git a/src/models/servarr_data/sonarr/sonarr_data.rs b/src/models/servarr_data/sonarr/sonarr_data.rs index 275e0ff..4707991 100644 --- a/src/models/servarr_data/sonarr/sonarr_data.rs +++ b/src/models/servarr_data/sonarr/sonarr_data.rs @@ -3,8 +3,9 @@ use strum::EnumIter; use crate::models::{ sonarr_models::{BlocklistItem, Series}, + stateful_list::StatefulList, stateful_table::StatefulTable, - Route, + HorizontallyScrollableText, Route, }; #[cfg(test)] @@ -16,6 +17,7 @@ pub struct SonarrData { pub start_time: DateTime, pub series: StatefulTable, pub blocklist: StatefulTable, + pub logs: StatefulList, } impl Default for SonarrData { @@ -25,6 +27,7 @@ impl Default for SonarrData { start_time: DateTime::default(), series: StatefulTable::default(), blocklist: StatefulTable::default(), + logs: StatefulList::default(), } } } diff --git a/src/models/servarr_data/sonarr/sonarr_data_tests.rs b/src/models/servarr_data/sonarr/sonarr_data_tests.rs index 6b9c590..86a1937 100644 --- a/src/models/servarr_data/sonarr/sonarr_data_tests.rs +++ b/src/models/servarr_data/sonarr/sonarr_data_tests.rs @@ -38,6 +38,7 @@ mod tests { assert_eq!(sonarr_data.start_time, >::default()); assert!(sonarr_data.series.is_empty()); assert!(sonarr_data.blocklist.is_empty()); + assert!(sonarr_data.logs.is_empty()); } } } diff --git a/src/models/sonarr_models.rs b/src/models/sonarr_models.rs index 32f4563..d3ea7c9 100644 --- a/src/models/sonarr_models.rs +++ b/src/models/sonarr_models.rs @@ -42,6 +42,23 @@ pub struct Language { pub name: String, } +#[derive(Default, Serialize, Deserialize, Clone, Debug, Eq, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct Log { + pub time: DateTime, + pub exception: Option, + pub exception_type: Option, + pub level: String, + pub logger: Option, + pub message: Option, + pub method: Option, +} + +#[derive(Default, Clone, Serialize, Deserialize, Debug, Eq, PartialEq)] +pub struct LogResponse { + pub records: Vec, +} + #[derive(Serialize, Deserialize, Default, Debug, Clone, PartialEq, Eq, Ord, PartialOrd)] pub struct Quality { pub name: String, @@ -215,6 +232,7 @@ pub enum SonarrSerdeable { SeriesVec(Vec), SystemStatus(SystemStatus), BlocklistResponse(BlocklistResponse), + LogResponse(LogResponse), } impl From for Serdeable { @@ -235,6 +253,7 @@ serde_enum_from!( SeriesVec(Vec), SystemStatus(SystemStatus), BlocklistResponse(BlocklistResponse), + LogResponse(LogResponse), } ); diff --git a/src/models/sonarr_models_tests.rs b/src/models/sonarr_models_tests.rs index cd47089..7b389c1 100644 --- a/src/models/sonarr_models_tests.rs +++ b/src/models/sonarr_models_tests.rs @@ -5,8 +5,8 @@ mod tests { use crate::models::{ sonarr_models::{ - BlocklistItem, BlocklistResponse, Series, SeriesStatus, SeriesType, SonarrSerdeable, - SystemStatus, + BlocklistItem, BlocklistResponse, Log, LogResponse, Series, SeriesStatus, SeriesType, + SonarrSerdeable, SystemStatus, }, Serdeable, }; @@ -109,4 +109,18 @@ mod tests { SonarrSerdeable::BlocklistResponse(blocklist_response) ); } + + #[test] + fn test_sonarr_serdeable_from_log_response() { + let log_response = LogResponse { + records: vec![Log { + level: "info".to_owned(), + ..Log::default() + }], + }; + + let sonarr_serdeable: SonarrSerdeable = log_response.clone().into(); + + assert_eq!(sonarr_serdeable, SonarrSerdeable::LogResponse(log_response)); + } } diff --git a/src/network/radarr_network.rs b/src/network/radarr_network.rs index 979e52b..936178b 100644 --- a/src/network/radarr_network.rs +++ b/src/network/radarr_network.rs @@ -194,7 +194,10 @@ impl<'a, 'b> Network<'a, 'b> { RadarrEvent::GetDownloads => self.get_downloads().await.map(RadarrSerdeable::from), RadarrEvent::GetHostConfig => self.get_host_config().await.map(RadarrSerdeable::from), RadarrEvent::GetIndexers => self.get_indexers().await.map(RadarrSerdeable::from), - RadarrEvent::GetLogs(events) => self.get_logs(events).await.map(RadarrSerdeable::from), + RadarrEvent::GetLogs(events) => self + .get_radarr_logs(events) + .await + .map(RadarrSerdeable::from), RadarrEvent::GetMovieCredits(movie_id) => { self.get_credits(movie_id).await.map(RadarrSerdeable::from) } @@ -1438,7 +1441,7 @@ impl<'a, 'b> Network<'a, 'b> { .await } - async fn get_logs(&mut self, events: Option) -> Result { + async fn get_radarr_logs(&mut self, events: Option) -> Result { info!("Fetching Radarr logs"); let event = RadarrEvent::GetLogs(events); diff --git a/src/network/radarr_network_tests.rs b/src/network/radarr_network_tests.rs index 97a7781..f386a11 100644 --- a/src/network/radarr_network_tests.rs +++ b/src/network/radarr_network_tests.rs @@ -2450,7 +2450,7 @@ mod test { } #[tokio::test] - async fn test_handle_get_logs_event() { + async fn test_handle_get_radarr_logs_event() { let expected_logs = vec![ HorizontallyScrollableText::from( "2023-05-20 21:29:16 UTC|FATAL|RadarrError|Some.Big.Bad.Exception|test exception", @@ -2518,7 +2518,7 @@ mod test { } #[tokio::test] - async fn test_handle_get_logs_event_uses_provided_events() { + async fn test_handle_get_radarr_logs_event_uses_provided_events() { let expected_logs = vec![ HorizontallyScrollableText::from( "2023-05-20 21:29:16 UTC|FATAL|RadarrError|Some.Big.Bad.Exception|test exception", diff --git a/src/network/sonarr_network.rs b/src/network/sonarr_network.rs index 2ad14be..2e87e22 100644 --- a/src/network/sonarr_network.rs +++ b/src/network/sonarr_network.rs @@ -5,9 +5,8 @@ use serde_json::{json, Value}; use crate::{ models::{ servarr_data::sonarr::sonarr_data::ActiveSonarrBlock, - sonarr_models::BlocklistResponse, - sonarr_models::{Series, SonarrSerdeable, SystemStatus}, - Route, + sonarr_models::{BlocklistResponse, LogResponse, Series, SonarrSerdeable, SystemStatus}, + HorizontallyScrollableText, Route, Scrollable, }, network::RequestMethod, }; @@ -22,6 +21,7 @@ pub enum SonarrEvent { ClearBlocklist, DeleteBlocklistItem(Option), GetBlocklist, + GetLogs(Option), GetStatus, HealthCheck, ListSeries, @@ -33,6 +33,7 @@ impl NetworkResource for SonarrEvent { SonarrEvent::ClearBlocklist => "/blocklist/bulk", SonarrEvent::DeleteBlocklistItem(_) => "/blocklist", SonarrEvent::GetBlocklist => "/blocklist?page=1&pageSize=10000", + SonarrEvent::GetLogs(_) => "/log", SonarrEvent::GetStatus => "/system/status", SonarrEvent::HealthCheck => "/health", SonarrEvent::ListSeries => "/series", @@ -61,6 +62,10 @@ impl<'a, 'b> Network<'a, 'b> { .await .map(SonarrSerdeable::from), SonarrEvent::GetBlocklist => self.get_sonarr_blocklist().await.map(SonarrSerdeable::from), + SonarrEvent::GetLogs(events) => self + .get_sonarr_logs(events) + .await + .map(SonarrSerdeable::from), SonarrEvent::GetStatus => self.get_sonarr_status().await.map(SonarrSerdeable::from), SonarrEvent::HealthCheck => self .get_sonarr_healthcheck() @@ -170,6 +175,53 @@ impl<'a, 'b> Network<'a, 'b> { .await } + async fn get_sonarr_logs(&mut self, events: Option) -> Result { + info!("Fetching Sonarr logs"); + let event = SonarrEvent::GetLogs(events); + + let params = format!( + "pageSize={}&sortDirection=descending&sortKey=time", + events.unwrap_or(500) + ); + let request_props = self + .request_props_from(event, RequestMethod::Get, None::<()>, None, Some(params)) + .await; + + self + .handle_request::<(), LogResponse>(request_props, |log_response, mut app| { + let mut logs = log_response.records; + logs.reverse(); + + let log_lines = logs + .into_iter() + .map(|log| { + if log.exception.is_some() { + HorizontallyScrollableText::from(format!( + "{}|{}|{}|{}|{}", + log.time, + log.level.to_uppercase(), + log.logger.as_ref().unwrap(), + log.exception_type.as_ref().unwrap(), + log.exception.as_ref().unwrap() + )) + } else { + HorizontallyScrollableText::from(format!( + "{}|{}|{}|{}", + log.time, + log.level.to_uppercase(), + log.logger.as_ref().unwrap(), + log.message.as_ref().unwrap() + )) + } + }) + .collect(); + + app.data.sonarr_data.logs.set_items(log_lines); + app.data.sonarr_data.logs.scroll_to_bottom(); + }) + .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 538fa85..84159ff 100644 --- a/src/network/sonarr_network_tests.rs +++ b/src/network/sonarr_network_tests.rs @@ -9,9 +9,10 @@ mod test { use tokio_util::sync::CancellationToken; use crate::models::servarr_data::sonarr::sonarr_data::ActiveSonarrBlock; - use crate::models::sonarr_models::{BlocklistItem, Language}; + use crate::models::sonarr_models::{BlocklistItem, Language, LogResponse}; use crate::models::sonarr_models::{BlocklistResponse, Quality}; use crate::models::sonarr_models::{QualityWrapper, SystemStatus}; + use crate::models::HorizontallyScrollableText; use crate::models::{sonarr_models::SonarrSerdeable, stateful_table::SortOption}; use crate::{ @@ -78,8 +79,9 @@ mod test { #[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")] + #[case(SonarrEvent::GetLogs(Some(500)), "/log")] + #[case(SonarrEvent::GetStatus, "/system/status")] fn test_resource(#[case] event: SonarrEvent, #[case] expected_uri: String) { assert_str_eq!(event.resource(), expected_uri); } @@ -276,6 +278,142 @@ mod test { async_server.assert_async().await; } + #[tokio::test] + async fn test_handle_get_sonarr_logs_event() { + let expected_logs = vec![ + HorizontallyScrollableText::from( + "2023-05-20 21:29:16 UTC|FATAL|RadarrError|Some.Big.Bad.Exception|test exception", + ), + HorizontallyScrollableText::from("2023-05-20 21:29:16 UTC|INFO|TestLogger|test message"), + ]; + let logs_response_json = json!({ + "page": 1, + "pageSize": 500, + "sortKey": "time", + "sortDirection": "descending", + "totalRecords": 2, + "records": [ + { + "time": "2023-05-20T21:29:16Z", + "level": "info", + "logger": "TestLogger", + "message": "test message", + "id": 1 + }, + { + "time": "2023-05-20T21:29:16Z", + "level": "fatal", + "logger": "RadarrError", + "exception": "test exception", + "exceptionType": "Some.Big.Bad.Exception", + "id": 2 + } + ] + }); + let response: LogResponse = serde_json::from_value(logs_response_json.clone()).unwrap(); + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(logs_response_json), + None, + SonarrEvent::GetLogs(None), + None, + Some("pageSize=500&sortDirection=descending&sortKey=time"), + ) + .await; + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + if let SonarrSerdeable::LogResponse(logs) = network + .handle_sonarr_event(SonarrEvent::GetLogs(None)) + .await + .unwrap() + { + async_server.assert_async().await; + assert_eq!( + app_arc.lock().await.data.sonarr_data.logs.items, + expected_logs + ); + assert!(app_arc + .lock() + .await + .data + .sonarr_data + .logs + .current_selection() + .text + .contains("INFO")); + assert_eq!(logs, response); + } + } + + #[tokio::test] + async fn test_handle_get_sonarr_logs_event_uses_provided_events() { + let expected_logs = vec![ + HorizontallyScrollableText::from( + "2023-05-20 21:29:16 UTC|FATAL|RadarrError|Some.Big.Bad.Exception|test exception", + ), + HorizontallyScrollableText::from("2023-05-20 21:29:16 UTC|INFO|TestLogger|test message"), + ]; + let logs_response_json = json!({ + "page": 1, + "pageSize": 1000, + "sortKey": "time", + "sortDirection": "descending", + "totalRecords": 2, + "records": [ + { + "time": "2023-05-20T21:29:16Z", + "level": "info", + "logger": "TestLogger", + "message": "test message", + "id": 1 + }, + { + "time": "2023-05-20T21:29:16Z", + "level": "fatal", + "logger": "RadarrError", + "exception": "test exception", + "exceptionType": "Some.Big.Bad.Exception", + "id": 2 + } + ] + }); + let response: LogResponse = serde_json::from_value(logs_response_json.clone()).unwrap(); + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(logs_response_json), + None, + SonarrEvent::GetLogs(Some(1000)), + None, + Some("pageSize=1000&sortDirection=descending&sortKey=time"), + ) + .await; + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + if let SonarrSerdeable::LogResponse(logs) = network + .handle_sonarr_event(SonarrEvent::GetLogs(Some(1000))) + .await + .unwrap() + { + async_server.assert_async().await; + assert_eq!( + app_arc.lock().await.data.sonarr_data.logs.items, + expected_logs + ); + assert!(app_arc + .lock() + .await + .data + .sonarr_data + .logs + .current_selection() + .text + .contains("INFO")); + assert_eq!(logs, response); + } + } + #[rstest] #[tokio::test] async fn test_handle_get_series_event(#[values(true, false)] use_custom_sorting: bool) { From 29047c3007f521c55abdca187908c6c34d24d0ff Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Mon, 11 Nov 2024 14:06:46 -0700 Subject: [PATCH 004/119] feat(sonarr): Added CLI support for listing Sonarr logs --- src/cli/sonarr/list_command_handler.rs | 29 ++++++++++ src/cli/sonarr/list_command_handler_tests.rs | 59 +++++++++++++++++++- 2 files changed, 85 insertions(+), 3 deletions(-) diff --git a/src/cli/sonarr/list_command_handler.rs b/src/cli/sonarr/list_command_handler.rs index b9cb31b..00756e3 100644 --- a/src/cli/sonarr/list_command_handler.rs +++ b/src/cli/sonarr/list_command_handler.rs @@ -21,6 +21,16 @@ mod list_command_handler_tests; pub enum SonarrListCommand { #[command(about = "List all items in the Sonarr blocklist")] Blocklist, + #[command(about = "Fetch Sonarr logs")] + Logs { + #[arg(long, help = "How many log events to fetch", default_value_t = 500)] + events: u64, + #[arg( + long, + help = "Output the logs in the same format as they appear in the log files" + )] + output_in_log_format: bool, + }, #[command(about = "List all series in your Sonarr library")] Series, } @@ -55,6 +65,25 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, SonarrListCommand> for SonarrListCommandH SonarrListCommand::Blocklist => { execute_network_event!(self, SonarrEvent::GetBlocklist); } + SonarrListCommand::Logs { + events, + output_in_log_format, + } => { + let logs = self + .network + .handle_network_event(SonarrEvent::GetLogs(Some(events)).into()) + .await?; + + if output_in_log_format { + let log_lines = self.app.lock().await.data.sonarr_data.logs.items.clone(); + + let json = serde_json::to_string_pretty(&log_lines)?; + println!("{}", json); + } else { + let json = serde_json::to_string_pretty(&logs)?; + println!("{}", json); + } + } 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 f505199..b7a5c9a 100644 --- a/src/cli/sonarr/list_command_handler_tests.rs +++ b/src/cli/sonarr/list_command_handler_tests.rs @@ -18,6 +18,8 @@ mod tests { mod cli { use super::*; + use clap::{error::ErrorKind, Parser}; + use pretty_assertions::assert_eq; use rstest::rstest; #[rstest] @@ -28,6 +30,30 @@ mod tests { assert!(result.is_ok()); } + + #[test] + fn test_list_logs_events_flag_requires_arguments() { + let result = + Cli::command().try_get_matches_from(["managarr", "sonarr", "list", "logs", "--events"]); + + assert!(result.is_err()); + assert_eq!(result.unwrap_err().kind(), ErrorKind::InvalidValue); + } + + #[test] + fn test_list_logs_default_values() { + let expected_args = SonarrListCommand::Logs { + events: 500, + output_in_log_format: false, + }; + let result = Cli::try_parse_from(["managarr", "sonarr", "list", "logs"]); + + assert!(result.is_ok()); + + if let Some(Command::Sonarr(SonarrCommand::List(refresh_command))) = result.unwrap().command { + assert_eq!(refresh_command, expected_args); + } + } } mod handler { @@ -39,8 +65,9 @@ mod tests { use serde_json::json; use tokio::sync::Mutex; - use crate::cli::sonarr::list_command_handler::SonarrListCommand; + use crate::cli::sonarr::list_command_handler::{SonarrListCommand, SonarrListCommandHandler}; use crate::cli::CliCommandHandler; + use crate::models::sonarr_models::SonarrSerdeable; use crate::network::sonarr_network::SonarrEvent; use crate::{ app::App, @@ -56,8 +83,6 @@ mod tests { #[case] list_command: SonarrListCommand, #[case] expected_sonarr_event: SonarrEvent, ) { - use crate::cli::sonarr::list_command_handler::SonarrListCommandHandler; - let mut mock_network = MockNetworkTrait::new(); mock_network .expect_handle_network_event() @@ -76,5 +101,33 @@ mod tests { assert!(result.is_ok()); } + + #[tokio::test] + async fn test_handle_list_logs_command() { + let expected_events = 1000; + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::( + SonarrEvent::GetLogs(Some(expected_events)).into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Sonarr(SonarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::default())); + let list_logs_command = SonarrListCommand::Logs { + events: 1000, + output_in_log_format: false, + }; + + let result = SonarrListCommandHandler::with(&app_arc, list_logs_command, &mut mock_network) + .handle() + .await; + + assert!(result.is_ok()); + } } } From 214c89e8b5d5957691d03a71d8ef5d175b09c0bc Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Fri, 15 Nov 2024 12:08:35 -0700 Subject: [PATCH 005/119] feat(models): Created the StatefulTree struct for displaying seasons and episodes (and any other structured data) for the UI. --- Cargo.lock | 1147 ++++++++++++++++++----------- Cargo.toml | 11 +- src/models/mod.rs | 1 + src/models/stateful_tree.rs | 62 ++ src/models/stateful_tree_tests.rs | 177 +++++ src/ui/widgets/managarr_table.rs | 2 +- 6 files changed, 953 insertions(+), 447 deletions(-) create mode 100644 src/models/stateful_tree.rs create mode 100644 src/models/stateful_tree_tests.rs diff --git a/Cargo.lock b/Cargo.lock index ecb8110..7f464e2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4,18 +4,18 @@ version = 3 [[package]] name = "addr2line" -version = "0.19.0" +version = "0.24.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a76fd60b23679b7d19bd066031410fb7e458ccc5e958eb5c325888ce4baedc97" +checksum = "dfbe277e56a376000877090da837660b4427aad530e3028d44e0bffe4f89a1c1" dependencies = [ "gimli", ] [[package]] -name = "adler" -version = "1.0.2" +name = "adler2" +version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe" +checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627" [[package]] name = "aho-corasick" @@ -28,9 +28,9 @@ dependencies = [ [[package]] name = "allocator-api2" -version = "0.2.18" +version = "0.2.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c6cb57a04249c6480766f7f7cef5467412af1490f8d1e243141daddada3264f" +checksum = "45862d1c77f2228b9e10bc609d5bc203d86ebc9b87ad8d5d5167a6c9abf739d9" [[package]] name = "android-tzdata" @@ -49,29 +49,14 @@ dependencies = [ [[package]] name = "anstream" -version = "0.2.6" +version = "0.6.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "342258dd14006105c2b75ab1bd7543a03bdf0cfc94383303ac212a04939dff6f" +checksum = "8acc5369981196006228e28809f761875c0327210a891e941f4c683b3a99529b" dependencies = [ - "anstyle 0.3.5", - "anstyle-parse 0.1.1", - "anstyle-wincon 0.2.0", - "concolor-override", - "concolor-query", - "is-terminal", - "utf8parse", -] - -[[package]] -name = "anstream" -version = "0.6.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23a1e53f0f5d86382dafe1cf314783b2044280f406e7e1506368220ad11b1338" -dependencies = [ - "anstyle 1.0.9", - "anstyle-parse 0.2.6", + "anstyle", + "anstyle-parse", "anstyle-query", - "anstyle-wincon 3.0.6", + "anstyle-wincon", "colorchoice", "is_terminal_polyfill", "utf8parse", @@ -79,24 +64,9 @@ dependencies = [ [[package]] name = "anstyle" -version = "0.3.5" +version = "1.0.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23ea9e81bd02e310c216d080f6223c179012256e5151c41db88d12c88a1684d2" - -[[package]] -name = "anstyle" -version = "1.0.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8365de52b16c035ff4fcafe0092ba9390540e3e352870ac09933bebcaa2c8c56" - -[[package]] -name = "anstyle-parse" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7d1bb534e9efed14f3e5f44e7dd1a4f709384023a4165199a4241e18dff0116" -dependencies = [ - "utf8parse", -] +checksum = "55cc3b69f167a1ef2e161439aa98aed94e6028e5f9a59be9a6ffb47aef1651f9" [[package]] name = "anstyle-parse" @@ -116,31 +86,21 @@ dependencies = [ "windows-sys 0.59.0", ] -[[package]] -name = "anstyle-wincon" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3127af6145b149f3287bb9a0d10ad9c5692dba8c53ad48285e5bec4063834fa" -dependencies = [ - "anstyle 0.3.5", - "windows-sys 0.45.0", -] - [[package]] name = "anstyle-wincon" version = "3.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2109dbce0e72be3ec00bed26e6a7479ca384ad226efdd66db8fa2e3a38c83125" dependencies = [ - "anstyle 1.0.9", + "anstyle", "windows-sys 0.59.0", ] [[package]] name = "anyhow" -version = "1.0.68" +version = "1.0.93" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2cb2f989d18dd141ab8ae82f64d1a8cdd37e0840f73a406896cf5e99502fab61" +checksum = "4c95c10ba0b00a02636238b814946408b1322d5ac4760326e6fb8ec956d85775" [[package]] name = "arc-swap" @@ -164,7 +124,7 @@ version = "2.0.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc1835b7f27878de8525dc71410b5a31cdcc5f230aed5ba5df968e09c201b23d" dependencies = [ - "anstyle 1.0.9", + "anstyle", "bstr", "doc-comment", "libc", @@ -182,9 +142,15 @@ checksum = "721cae7de5c34fbb2acd27e21e6d2cf7b886dce0c27388d46c4e6c47ea4318dd" dependencies = [ "proc-macro2", "quote", - "syn 2.0.85", + "syn 2.0.87", ] +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + [[package]] name = "autocfg" version = "1.4.0" @@ -193,24 +159,24 @@ checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" [[package]] name = "backtrace" -version = "0.3.67" +version = "0.3.74" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "233d376d6d185f2a3093e58f283f60f880315b6c60075b01f36b3b85154564ca" +checksum = "8d82cb332cdfaed17ae235a638438ac4d4839913cc2af585c3c6746e8f8bee1a" dependencies = [ "addr2line", - "cc", "cfg-if", "libc", "miniz_oxide", "object", "rustc-demangle", + "windows-targets 0.52.6", ] [[package]] name = "base64" -version = "0.21.7" +version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" [[package]] name = "bimap" @@ -258,9 +224,9 @@ checksum = "9ac0150caa2ae65ca5bd83f25c7de183dea78d4d366469f148435e2acfbad0da" [[package]] name = "cargo-husky" -version = "1.0.0" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa108bb6da8de0669ab0fef3a4afabcc3446938b09b1ffe2e90486c75df8f215" +checksum = "7b02b629252fe8ef6460461409564e2c21d0c8e77e0944f3d189ff06c4e932ad" [[package]] name = "cassowary" @@ -279,9 +245,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.1.31" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2e7962b54006dcfcc61cb72735f4d89bb97061dd6a7ed882ec6b8ee53714c6f" +checksum = "1aeb932158bd710538c73702db6945cb68a8fb08c519e6e12706b94263b36db8" dependencies = [ "shlex", ] @@ -315,9 +281,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.5.20" +version = "4.5.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b97f376d85a664d5837dbae44bf546e6477a679ff6610010f17276f686d867e8" +checksum = "fb3b4b9e5a7c7514dfa52869339ee98b3156b0bfb4e8a77c4ff4babb64b1604f" dependencies = [ "clap_builder", "clap_derive", @@ -325,21 +291,21 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.20" +version = "4.5.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19bc80abd44e4bed93ca373a0704ccbd1b710dc5749406201bb018272808dc54" +checksum = "b17a95aa67cc7b5ebd32aa5370189aa0d79069ef1c64ce893bd30fb24bff20ec" dependencies = [ - "anstream 0.6.17", - "anstyle 1.0.9", + "anstream", + "anstyle", "clap_lex", "strsim", ] [[package]] name = "clap_complete" -version = "4.5.33" +version = "4.5.38" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9646e2e245bf62f45d39a0f3f36f1171ad1ea0d6967fd114bca72cb02a8fcdfb" +checksum = "d9647a559c112175f17cf724dc72d3645680a883c58481332779192b0d8e7a01" dependencies = [ "clap", ] @@ -353,14 +319,14 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.85", + "syn 2.0.87", ] [[package]] name = "clap_lex" -version = "0.7.2" +version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1462739cb27611015575c0c11df5df7601141071f07518d56fcc1be504cbec97" +checksum = "afb84c814227b90d6895e01398aee0d8033c00e7466aca416fb6a8e0eb19d8a7" [[package]] name = "colorchoice" @@ -392,33 +358,15 @@ dependencies = [ "static_assertions", ] -[[package]] -name = "concolor-override" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40a4925288e39d5923e024781971aab940995fa31bab3ffceebbadfc87591e90" -dependencies = [ - "colorchoice", -] - -[[package]] -name = "concolor-query" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88d11d52c3d7ca2e6d0040212be9e4dbbcd78b6447f535b6b561f449427944cf" -dependencies = [ - "windows-sys 0.45.0", -] - [[package]] name = "confy" -version = "0.6.0" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15d296c475c6ed4093824c28e222420831d27577aaaf0a1163a3b7fc35b248a5" +checksum = "45b1f4c00870f07dc34adcac82bb6a72cc5aabca8536ba1797e01df51d2ce9a0" dependencies = [ "directories", "serde", - "serde_yaml 0.9.16", + "serde_yaml", "thiserror", ] @@ -438,22 +386,6 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" -[[package]] -name = "crossterm" -version = "0.27.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f476fe445d41c9e991fd07515a6f463074b782242ccf4a5b7b1d1012e70824df" -dependencies = [ - "bitflags", - "crossterm_winapi", - "libc", - "mio 0.8.11", - "parking_lot", - "signal-hook", - "signal-hook-mio", - "winapi", -] - [[package]] name = "crossterm" version = "0.28.1" @@ -462,7 +394,7 @@ checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" dependencies = [ "bitflags", "crossterm_winapi", - "mio 1.0.2", + "mio", "parking_lot", "rustix", "signal-hook", @@ -479,16 +411,6 @@ dependencies = [ "winapi", ] -[[package]] -name = "ctor" -version = "0.1.26" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d2301688392eb071b0bf1a37be05c469d3cc4dbbd95df672fe28ab021e6a096" -dependencies = [ - "quote", - "syn 1.0.109", -] - [[package]] name = "ctrlc" version = "3.4.5" @@ -499,6 +421,41 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "darling" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f63b86c8a8826a49b8c21f08a2d07338eec8d900540f8630dc76284be802989" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95133861a8032aaea082871032f5815eb9e98cef03fa916ab4500513994df9e5" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.87", +] + +[[package]] +name = "darling_macro" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d336a2a514f6ccccaa3e09b02d41d35330c07ddf03a62165fcec10bb561c7806" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.87", +] + [[package]] name = "deranged" version = "0.3.11" @@ -579,6 +536,17 @@ dependencies = [ "winapi", ] +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.87", +] + [[package]] name = "doc-comment" version = "0.3.3" @@ -624,9 +592,9 @@ dependencies = [ [[package]] name = "fastrand" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8c02a5121d4ea3eb16a80748c74f5549a5665e4c21333c6098f283870fbdea6" +checksum = "486f806e73c5707928240ddc295403b1b93c96a02038563881c4a2fd84b81ac4" [[package]] name = "fnv" @@ -726,7 +694,7 @@ checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" dependencies = [ "proc-macro2", "quote", - "syn 2.0.85", + "syn 2.0.87", ] [[package]] @@ -778,9 +746,9 @@ dependencies = [ [[package]] name = "gimli" -version = "0.27.3" +version = "0.31.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6c80984affa11d98d1b88b66ac8853f143217b399d3c74116778ff8fdb4ed2e" +checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" [[package]] name = "glob" @@ -790,17 +758,17 @@ checksum = "d2fabcfbdc87f4758337ca535fb41a6d701b65693ce38287d856d1674551ec9b" [[package]] name = "h2" -version = "0.3.26" +version = "0.4.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81fe527a889e1532da5c525686d96d4c2e74cdd345badf8dfef9f6b39dd5f5e8" +checksum = "524e8ac6999421f49a846c2d4411f337e53497d8ec55d67753beffa43c5d9205" dependencies = [ + "atomic-waker", "bytes", "fnv", "futures-core", "futures-sink", - "futures-util", "http", - "indexmap 2.6.0", + "indexmap", "slab", "tokio", "tokio-util", @@ -809,15 +777,9 @@ dependencies = [ [[package]] name = "hashbrown" -version = "0.12.3" +version = "0.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" - -[[package]] -name = "hashbrown" -version = "0.15.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e087f84d4f86bf4b218b927129862374b72199ae7d8657835f1e89000eea4fb" +checksum = "3a9bfc1af68b1726ea47d3d5109de126281def866b33970e10fbab11b5dafab3" dependencies = [ "allocator-api2", "equivalent", @@ -836,17 +798,11 @@ version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024" -[[package]] -name = "hermit-abi" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fbf6a919d6cf397374f7dfeeea91d974c7c0a7221d0d0f4f20d859d329e53fcc" - [[package]] name = "http" -version = "0.2.12" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" +checksum = "21b9ddb458710bc376481b842f5da65cdf31522de232c1ca8146abce2a358258" dependencies = [ "bytes", "fnv", @@ -855,12 +811,24 @@ dependencies = [ [[package]] name = "http-body" -version = "0.4.6" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" dependencies = [ "bytes", "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "793429d76616a256bcb62c2a2ec2bed781c8307e797e2598c50010f2bee2544f" +dependencies = [ + "bytes", + "futures-util", + "http", + "http-body", "pin-project-lite", ] @@ -878,12 +846,12 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" [[package]] name = "human-panic" -version = "1.1.3" +version = "2.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0a6557b29bbdc9d6c7a5cdbe2962e78eaf48115e8d55b0b62282956981c1f605" +checksum = "80b84a66a325082740043a6c28bbea400c129eac0d3a27673a1de971e44bf1f7" dependencies = [ - "anstream 0.2.6", - "anstyle 0.3.5", + "anstream", + "anstyle", "backtrace", "os_info", "serde", @@ -900,13 +868,12 @@ checksum = "9a3a5bfb195931eeb336b2a7b4d761daec841b97f947d34394601737a7bba5e4" [[package]] name = "hyper" -version = "0.14.31" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c08302e8fa335b151b788c775ff56e7a03ae64ff85c548ee820fecb70356e85" +checksum = "bbbff0a806a4728c99295b254c8838933b5b082d75e3cb70c8dab21fdfbcfa9a" dependencies = [ "bytes", "futures-channel", - "futures-core", "futures-util", "h2", "http", @@ -915,24 +882,61 @@ dependencies = [ "httpdate", "itoa", "pin-project-lite", - "socket2", + "smallvec", "tokio", - "tower-service", - "tracing", "want", ] [[package]] -name = "hyper-tls" -version = "0.5.0" +name = "hyper-rustls" +version = "0.27.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6183ddfa99b85da61a140bea0efc93fdf56ceaa041b37d553518030827f9905" +checksum = "08afdbb5c31130e3034af566421053ab03787c640246a446327f550d11bcb333" +dependencies = [ + "futures-util", + "http", + "hyper", + "hyper-util", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-tls" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" dependencies = [ "bytes", + "http-body-util", "hyper", + "hyper-util", "native-tls", "tokio", "tokio-native-tls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df2dcfbe0677734ab2f3ffa7fa7bfd4706bfdc1ef393f2ee30184aed67e631b4" +dependencies = [ + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", ] [[package]] @@ -959,23 +963,148 @@ dependencies = [ ] [[package]] -name = "idna" -version = "0.5.0" +name = "icu_collections" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "634d9b1461af396cad843f47fdba5597a4f9e6ddd4bfb6ff5d85028c25cb12f6" +checksum = "db2fa452206ebee18c4b5c2274dbf1de17008e874b4dc4f0aea9d01ca79e4526" dependencies = [ - "unicode-bidi", - "unicode-normalization", + "displaydoc", + "yoke", + "zerofrom", + "zerovec", ] [[package]] -name = "indexmap" -version = "1.9.3" +name = "icu_locid" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +checksum = "13acbb8371917fc971be86fc8057c41a64b521c184808a698c02acc242dbf637" dependencies = [ - "autocfg", - "hashbrown 0.12.3", + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_locid_transform" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01d11ac35de8e40fdeda00d9e1e9d92525f3f9d887cdd7aa81d727596788b54e" +dependencies = [ + "displaydoc", + "icu_locid", + "icu_locid_transform_data", + "icu_provider", + "tinystr", + "zerovec", +] + +[[package]] +name = "icu_locid_transform_data" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdc8ff3388f852bede6b579ad4e978ab004f139284d7b28715f773507b946f6e" + +[[package]] +name = "icu_normalizer" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19ce3e0da2ec68599d193c93d088142efd7f9c5d6fc9b803774855747dc6a84f" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "utf16_iter", + "utf8_iter", + "write16", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8cafbf7aa791e9b22bec55a167906f9e1215fd475cd22adfcf660e03e989516" + +[[package]] +name = "icu_properties" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93d6020766cfc6302c15dbbc9c8778c37e62c14427cb7f6e601d849e092aeef5" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locid_transform", + "icu_properties_data", + "icu_provider", + "tinystr", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67a8effbc3dd3e4ba1afa8ad918d5684b8868b3b26500753effea8d2eed19569" + +[[package]] +name = "icu_provider" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ed421c8a8ef78d3e2dbc98a973be2f3770cb42b606e3ab18d6237c4dfde68d9" +dependencies = [ + "displaydoc", + "icu_locid", + "icu_provider_macros", + "stable_deref_trait", + "tinystr", + "writeable", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_provider_macros" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ec89e9337638ecdc08744df490b221a7399bf8d164eb52a665454e60e075ad6" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.87", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "686f825264d630750a544639377bae737628043f20d38bbc029e8f29ea968a7e" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daca1df1c957320b2cf139ac61e7bd64fed304c5040df000a745aa1de3b4ef71" +dependencies = [ + "icu_normalizer", + "icu_properties", ] [[package]] @@ -985,23 +1114,27 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "707907fe3c25f5424cce2cb7e1cbcafee6bdbe735ca90ef77c29e84591e5b9da" dependencies = [ "equivalent", - "hashbrown 0.15.0", + "hashbrown", ] [[package]] name = "indoc" -version = "2.0.0" +version = "2.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6fe2b9d82064e8a0226fddb3547f37f28eaa46d0fc210e275d835f08cf3b76a7" +checksum = "b248f5224d1d606005e02c97f5aa4e88eeb230488bcc03bc9ca4d7991399f2b5" [[package]] name = "instability" -version = "0.3.2" +version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b23a0c8dfe501baac4adf6ebbfa6eddf8f0c07f56b058cc1288017e32397846c" +checksum = "b829f37dead9dc39df40c2d3376c179fdfd2ac771f53f55d3c30dc096a3c0c6e" dependencies = [ + "darling", + "indoc", + "pretty_assertions", + "proc-macro2", "quote", - "syn 2.0.85", + "syn 2.0.87", ] [[package]] @@ -1010,17 +1143,6 @@ version = "2.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ddc24109865250148c2e0f3d25d4f0f479571723792d3802153c60922a4fb708" -[[package]] -name = "is-terminal" -version = "0.4.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "261f68e344040fbd0edea105bef17c66edf46f984ddb1115b775ce31be948f4b" -dependencies = [ - "hermit-abi 0.4.0", - "libc", - "windows-sys 0.52.0", -] - [[package]] name = "is_terminal_polyfill" version = "1.70.1" @@ -1059,9 +1181,9 @@ checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" [[package]] name = "libc" -version = "0.2.161" +version = "0.2.162" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e9489c2807c139ffd9c1794f4af0ebe86a828db53ecdc7fea2111d0fed085d1" +checksum = "18d287de67fe55fd7e1581fe933d965a5a9477b38e949cfa9f8574ef01506398" [[package]] name = "libredox" @@ -1073,18 +1195,18 @@ dependencies = [ "libc", ] -[[package]] -name = "linked-hash-map" -version = "0.5.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" - [[package]] name = "linux-raw-sys" version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78b3ae25bc7c8c38cec158d1f2757ee79e9b3740fbc7ccf0e59e4b08d793fa89" +[[package]] +name = "litemap" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "643cb0b8d4fcc284004d5fd0d67ccf61dfffadb7f75e1e71bc420f4688a3a704" + [[package]] name = "lock_api" version = "0.4.12" @@ -1097,11 +1219,10 @@ dependencies = [ [[package]] name = "log" -version = "0.4.17" +version = "0.4.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "abb12e687cfb44aa40f41fc3978ef76448f9b6038cad6aef4259d3c095a2382e" +checksum = "a7a70ba024b9dc04c27ea2f0c0548feb474ec5c54bba33a7f72f873a39d07b24" dependencies = [ - "cfg-if", "serde", ] @@ -1113,9 +1234,9 @@ checksum = "a94d21414c1f4a51209ad204c1776a3d0765002c76c6abcb602a6f09f1e881c7" [[package]] name = "log4rs" -version = "1.2.0" +version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d36ca1786d9e79b8193a68d480a0907b612f109537115c6ff655a3a1967533fd" +checksum = "0816135ae15bd0391cf284eab37e6e3ee0a6ee63d2ceeb659862bd8d0a984ca6" dependencies = [ "anyhow", "arc-swap", @@ -1126,11 +1247,13 @@ dependencies = [ "libc", "log", "log-mdc", + "once_cell", "parking_lot", + "rand", "serde", "serde-value", "serde_json", - "serde_yaml 0.8.26", + "serde_yaml", "thiserror", "thread-id", "typemap-ors", @@ -1143,7 +1266,7 @@ version = "0.12.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" dependencies = [ - "hashbrown 0.15.0", + "hashbrown", ] [[package]] @@ -1161,7 +1284,7 @@ dependencies = [ "clap_complete", "colored", "confy", - "crossterm 0.27.0", + "crossterm", "ctrlc", "derivative", "dirs-next", @@ -1170,6 +1293,7 @@ dependencies = [ "itertools", "log", "log4rs", + "managarr-tree-widget", "mockall", "mockito", "pretty_assertions", @@ -1179,7 +1303,7 @@ dependencies = [ "rstest", "serde", "serde_json", - "serde_yaml 0.9.16", + "serde_yaml", "strum", "strum_macros", "tokio", @@ -1187,6 +1311,15 @@ dependencies = [ "urlencoding", ] +[[package]] +name = "managarr-tree-widget" +version = "0.24.0" +source = "git+https://github.com/Dark-Alex-17/managarr-tree-widget.git#6baef469c8d20f7f1e567edba9886401ea9baa7c" +dependencies = [ + "ratatui", + "unicode-width 0.2.0", +] + [[package]] name = "memchr" version = "2.7.4" @@ -1201,23 +1334,11 @@ checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" [[package]] name = "miniz_oxide" -version = "0.6.2" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b275950c28b37e794e8c55d88aeb5e139d0ce23fdbbeda68f8d7174abdf9e8fa" +checksum = "e2d80299ef12ff69b16a84bb182e3b9df68b5a91574d3d4fa6e41b65deec4df1" dependencies = [ - "adler", -] - -[[package]] -name = "mio" -version = "0.8.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c" -dependencies = [ - "libc", - "log", - "wasi", - "windows-sys 0.48.0", + "adler2", ] [[package]] @@ -1226,7 +1347,7 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "80e04d1dcff3aae0704555fe5fee3bcfaf3d1fdf8a7e521d5b9d2b42acb52cec" dependencies = [ - "hermit-abi 0.3.9", + "hermit-abi", "libc", "log", "wasi", @@ -1256,20 +1377,24 @@ dependencies = [ "cfg-if", "proc-macro2", "quote", - "syn 2.0.85", + "syn 2.0.87", ] [[package]] name = "mockito" -version = "1.0.0" +version = "1.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c1eecc3baf782e3c8d6803cc8780268da1f32df6eb88c016c1d80b0df7944cf" +checksum = "652cd6d169a36eaf9d1e6bce1a221130439a966d7f27858af66a33a66e9c4ee2" dependencies = [ "assert-json-diff", + "bytes", "colored", - "futures", + "futures-util", + "http", + "http-body", + "http-body-util", "hyper", - "lazy_static", + "hyper-util", "log", "rand", "regex", @@ -1323,16 +1448,6 @@ dependencies = [ "autocfg", ] -[[package]] -name = "num_cpus" -version = "1.16.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4161fcb6d602d4d2081af7c3a45852d875a03dd337a6bfdd6e06407b61342a43" -dependencies = [ - "hermit-abi 0.3.9", - "libc", -] - [[package]] name = "num_threads" version = "0.1.7" @@ -1344,9 +1459,9 @@ dependencies = [ [[package]] name = "object" -version = "0.30.4" +version = "0.36.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03b4680b86d9cfafba8fc491dc9b6df26b68cf40e9e6cd73909194759a63c385" +checksum = "aedf0a2d09c573ed1d8d85b30c119153926a2b36dce0ab28322c09a117a4683e" dependencies = [ "memchr", ] @@ -1380,7 +1495,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.85", + "syn 2.0.87", ] [[package]] @@ -1427,15 +1542,6 @@ dependencies = [ "windows-sys 0.52.0", ] -[[package]] -name = "output_vt100" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "628223faebab4e3e40667ee0b2336d34a5b960ff60ea743ddfdbcf7770bcfb66" -dependencies = [ - "winapi", -] - [[package]] name = "parking_lot" version = "0.12.3" @@ -1510,7 +1616,7 @@ version = "3.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7e9086cc7640c29a356d1a29fd134380bee9d8f79a17410aa76e7ad295f42c97" dependencies = [ - "anstyle 1.0.9", + "anstyle", "difflib", "predicates-core", ] @@ -1533,16 +1639,23 @@ dependencies = [ [[package]] name = "pretty_assertions" -version = "1.3.0" +version = "1.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a25e9bcb20aa780fd0bb16b72403a9064d6b3f22f026946029acb941a50af755" +checksum = "3ae130e2f271fbc2ac3a40fb1d07180839cdbbe443c7a27e1e3c13c5cac0116d" dependencies = [ - "ctor", "diff", - "output_vt100", "yansi", ] +[[package]] +name = "proc-macro-crate" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecf48c7ca261d60b74ab1a7b20da18bede46776b2e55535cb958eb595c5fa7b" +dependencies = [ + "toml_edit", +] + [[package]] name = "proc-macro2" version = "1.0.89" @@ -1593,24 +1706,24 @@ dependencies = [ [[package]] name = "ratatui" -version = "0.28.0" +version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ba6a365afbe5615999275bea2446b970b10a41102500e27ce7678d50d978303" +checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b" dependencies = [ "bitflags", "cassowary", "compact_str", - "crossterm 0.28.1", + "crossterm", + "indoc", "instability", "itertools", "lru", "paste", "strum", - "strum_macros", "time", "unicode-segmentation", "unicode-truncate", - "unicode-width", + "unicode-width 0.2.0", ] [[package]] @@ -1647,9 +1760,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.8" +version = "0.4.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "368758f23274712b504848e9d5a6f010445cc8b87a7cdb4d7cbee666c1288da3" +checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" dependencies = [ "aho-corasick", "memchr", @@ -1670,9 +1783,9 @@ checksum = "ba39f3699c378cd8970968dcbff9c43159ea4cfbd88d43c00b22f2ef10a435d2" [[package]] name = "reqwest" -version = "0.11.14" +version = "0.12.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21eed90ec8570952d53b772ecf8f206aa1ec9a3d76b2521c56c42973f2d91ee9" +checksum = "a77c62af46e79de0a562e1a9849205ffcb7fc1238876e9bd743357570e04046f" dependencies = [ "base64", "bytes", @@ -1682,8 +1795,11 @@ dependencies = [ "h2", "http", "http-body", + "http-body-util", "hyper", + "hyper-rustls", "hyper-tls", + "hyper-util", "ipnet", "js-sys", "log", @@ -1692,9 +1808,12 @@ dependencies = [ "once_cell", "percent-encoding", "pin-project-lite", + "rustls-pemfile", "serde", "serde_json", "serde_urlencoded", + "sync_wrapper", + "system-configuration", "tokio", "tokio-native-tls", "tower-service", @@ -1702,14 +1821,29 @@ dependencies = [ "wasm-bindgen", "wasm-bindgen-futures", "web-sys", - "winreg", + "windows-registry", +] + +[[package]] +name = "ring" +version = "0.17.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c17fa4cb658e3583423e915b9f3acc01cceaee1860e33d59ebae66adc3a2dc0d" +dependencies = [ + "cc", + "cfg-if", + "getrandom", + "libc", + "spin", + "untrusted", + "windows-sys 0.52.0", ] [[package]] name = "rstest" -version = "0.18.2" +version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97eeab2f3c0a199bc4be135c36c924b6590b88c377d416494288c14f2db30199" +checksum = "0a2c585be59b6b5dd66a9d2084aa1d8bd52fbdb806eafdeffb52791147862035" dependencies = [ "futures", "futures-timer", @@ -1719,18 +1853,19 @@ dependencies = [ [[package]] name = "rstest_macros" -version = "0.18.2" +version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d428f8247852f894ee1be110b375111b586d4fa431f6c46e64ba5a0dcccbe605" +checksum = "825ea780781b15345a146be27eaefb05085e337e869bff01b4306a4fd4a9ad5a" dependencies = [ "cfg-if", "glob", + "proc-macro-crate", "proc-macro2", "quote", "regex", "relative-path", "rustc_version", - "syn 2.0.85", + "syn 2.0.87", "unicode-ident", ] @@ -1751,9 +1886,9 @@ dependencies = [ [[package]] name = "rustix" -version = "0.38.38" +version = "0.38.40" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa260229e6538e52293eeb577aabd09945a09d6d9cc0fc550ed7529056c2e32a" +checksum = "99e4ea3e1cdc4b559b8e5650f9c8e5998e3e5c1343b4eaf034565f32318d63c0" dependencies = [ "bitflags", "errno", @@ -1762,6 +1897,45 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rustls" +version = "0.23.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eee87ff5d9b36712a58574e12e9f0ea80f915a5b0ac518d322b24a465617925e" +dependencies = [ + "once_cell", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pemfile" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "rustls-pki-types" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16f1201b3c9a7ee8039bcadc17b7e605e2945b27eee7631788c1bd2b0643674b" + +[[package]] +name = "rustls-webpki" +version = "0.102.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64ca1bc8749bd4cf37b5ce386cc146580777b4e8572c7b97baf22c83f444bee9" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "rustversion" version = "1.0.18" @@ -1804,9 +1978,9 @@ dependencies = [ [[package]] name = "security-framework-sys" -version = "2.12.0" +version = "2.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea4a292869320c0272d7bc55a5a6aafaff59b4f63404a003887b679a2e05b4b6" +checksum = "fa39c7303dc58b5543c94d22c1766b0d31f2ee58306363ea622b10bbc075eaa2" dependencies = [ "core-foundation-sys", "libc", @@ -1820,9 +1994,9 @@ checksum = "61697e0a1c7e512e84a621326239844a24d8207b4669b41bc18b32ea5cbf988b" [[package]] name = "serde" -version = "1.0.214" +version = "1.0.215" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f55c3193aca71c12ad7890f1785d2b73e1b9f63a0bbc353c08ef26fe03fc56b5" +checksum = "6513c1ad0b11a9376da888e3e0baa0077f1aed55c17f50e7b2397136129fb88f" dependencies = [ "serde_derive", ] @@ -1839,22 +2013,23 @@ dependencies = [ [[package]] name = "serde_derive" -version = "1.0.214" +version = "1.0.215" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de523f781f095e28fa605cdce0f8307e451cc0fd14e2eb4cd2e98a355b147766" +checksum = "ad1e866f866923f252f05c889987993144fb74e722403468a4ebd70c3cd756c0" dependencies = [ "proc-macro2", "quote", - "syn 2.0.85", + "syn 2.0.87", ] [[package]] name = "serde_json" -version = "1.0.91" +version = "1.0.132" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877c235533714907a8c2464236f5c4b2a17262ef1bd71f38f35ea592c8da6883" +checksum = "d726bfaff4b320266d395898905d0eba0345aae23b54aee3a737e260fd46db03" dependencies = [ "itoa", + "memchr", "ryu", "serde", ] @@ -1882,23 +2057,11 @@ dependencies = [ [[package]] name = "serde_yaml" -version = "0.8.26" +version = "0.9.34+deprecated" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "578a7433b776b56a35785ed5ce9a7e777ac0598aac5a6dd1b4b18a307c7fc71b" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" dependencies = [ - "indexmap 1.9.3", - "ryu", - "serde", - "yaml-rust", -] - -[[package]] -name = "serde_yaml" -version = "0.9.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92b5b431e8907b50339b51223b97d102db8d987ced36f6e4d03621db9316c834" -dependencies = [ - "indexmap 1.9.3", + "indexmap", "itoa", "ryu", "serde", @@ -1928,8 +2091,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34db1a06d485c9142248b7a054f034b349b212551f3dfd19c94d45a754a217cd" dependencies = [ "libc", - "mio 0.8.11", - "mio 1.0.2", + "mio", "signal-hook", ] @@ -1973,6 +2135,18 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" + +[[package]] +name = "stable_deref_trait" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" + [[package]] name = "static_assertions" version = "1.1.0" @@ -2004,9 +2178,15 @@ dependencies = [ "proc-macro2", "quote", "rustversion", - "syn 2.0.85", + "syn 2.0.87", ] +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "syn" version = "1.0.109" @@ -2020,9 +2200,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.85" +version = "2.0.87" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5023162dfcd14ef8f32034d8bcd4cc5ddc61ef7a247c024a33e24e1f24d21b56" +checksum = "25aa4ce346d03a6dcd68dd8b4010bcb74e54e62c90c573f394c46eae99aba32d" dependencies = [ "proc-macro2", "quote", @@ -2030,10 +2210,51 @@ dependencies = [ ] [[package]] -name = "tempfile" -version = "3.13.0" +name = "sync_wrapper" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0f2c9fc62d0beef6951ccffd757e241266a2c833136efbe35af6cd2567dca5b" +checksum = "a7065abeca94b6a8a577f9bd45aa0867a2238b74e8eb67cf10d492bc39351394" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8af7666ab7b6390ab78131fb5b0fce11d6b7a6951602017c35fa82800708971" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.87", +] + +[[package]] +name = "system-configuration" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" +dependencies = [ + "bitflags", + "core-foundation", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "tempfile" +version = "3.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28cce251fcbc87fac86a866eeb0d6c2d536fc16d06f184bb61aeae11aa4cee0c" dependencies = [ "cfg-if", "fastrand", @@ -2050,22 +2271,22 @@ checksum = "3369f5ac52d5eb6ab48c6b4ffdc8efbcad6b89c765749064ba298f2c68a16a76" [[package]] name = "thiserror" -version = "1.0.65" +version = "1.0.69" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d11abd9594d9b38965ef50805c5e469ca9cc6f197f883f717e0269a3057b3d5" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" dependencies = [ "thiserror-impl", ] [[package]] name = "thiserror-impl" -version = "1.0.65" +version = "1.0.69" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae71770322cbd277e69d762a16c444af02aa0575ac0d174f0b9562d3b37f8602" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.85", + "syn 2.0.87", ] [[package]] @@ -2100,48 +2321,42 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ef927ca75afb808a4d64dd374f00a2adf8d0fcff8e7b184af886c3c87ec4a3f3" [[package]] -name = "tinyvec" -version = "1.8.0" +name = "tinystr" +version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "445e881f4f6d382d5f27c034e25eb92edd7c784ceab92a0937db7f2e9471b938" +checksum = "9117f5d4db391c1cf6927e7bea3db74b9a1c1add8f7eda9ffd5364f40f57b82f" dependencies = [ - "tinyvec_macros", + "displaydoc", + "zerovec", ] -[[package]] -name = "tinyvec_macros" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" - [[package]] name = "tokio" -version = "1.36.0" +version = "1.41.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61285f6515fa018fb2d1e46eb21223fff441ee8db5d0f1435e8ab4f5cdb80931" +checksum = "22cfb5bee7a6a52939ca9224d6ac897bb669134078daa8735560897f69de4d33" dependencies = [ "backtrace", "bytes", "libc", - "mio 0.8.11", - "num_cpus", + "mio", "parking_lot", "pin-project-lite", "signal-hook-registry", "socket2", "tokio-macros", - "windows-sys 0.48.0", + "windows-sys 0.52.0", ] [[package]] name = "tokio-macros" -version = "2.2.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b8a1e28f2deaa14e508979454cb3a223b10b938b45af148bc0986de36f1923b" +checksum = "693d596312e88961bc67d7f1f97af8a70227d9f90c31bba5806eec004978d752" dependencies = [ "proc-macro2", "quote", - "syn 2.0.85", + "syn 2.0.87", ] [[package]] @@ -2155,24 +2370,34 @@ dependencies = [ ] [[package]] -name = "tokio-util" -version = "0.7.8" +name = "tokio-rustls" +version = "0.26.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "806fe8c2c87eccc8b3267cbae29ed3ab2d0bd37fca70ab622e46aaa9375ddb7d" +checksum = "0c7bc40d0e5a97695bb96e27995cd3a08538541b0a846f65bba7a359f36700d4" +dependencies = [ + "rustls", + "rustls-pki-types", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61e7c3654c13bcd040d4a03abee2c75b1d14a37b423cf5a813ceae1cc903ec6a" dependencies = [ "bytes", "futures-core", "futures-sink", "pin-project-lite", "tokio", - "tracing", ] [[package]] name = "toml" -version = "0.7.8" +version = "0.8.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd79e69d3b627db300ff956027cc6c3798cef26d22526befdfcd12feeb6d2257" +checksum = "a1ed1f98e3fdc28d6d910e6737ae6ab1a93bf1985935a1193e68f93eeb68d24e" dependencies = [ "serde", "serde_spanned", @@ -2191,11 +2416,11 @@ dependencies = [ [[package]] name = "toml_edit" -version = "0.19.15" +version = "0.22.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" +checksum = "4ae48d6208a266e853d946088ed816055e556cc6028c5e8e2b84d9fa5dd7c7f5" dependencies = [ - "indexmap 2.6.0", + "indexmap", "serde", "serde_spanned", "toml_datetime", @@ -2242,27 +2467,12 @@ dependencies = [ "unsafe-any-ors", ] -[[package]] -name = "unicode-bidi" -version = "0.3.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ab17db44d7388991a428b2ee655ce0c212e862eff1768a455c58f9aad6e7893" - [[package]] name = "unicode-ident" version = "1.0.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e91b56cd4cadaeb79bbf1a5645f6b4f8dc5bde8834ad5894a8db35fda9efa1fe" -[[package]] -name = "unicode-normalization" -version = "0.1.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5033c97c4262335cded6d6fc3e5c18ab755e1a3dc96376350f3d8e9f009ad956" -dependencies = [ - "tinyvec", -] - [[package]] name = "unicode-segmentation" version = "1.12.0" @@ -2277,7 +2487,7 @@ checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" dependencies = [ "itertools", "unicode-segmentation", - "unicode-width", + "unicode-width 0.1.14", ] [[package]] @@ -2286,6 +2496,12 @@ version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" +[[package]] +name = "unicode-width" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" + [[package]] name = "unsafe-any-ors" version = "1.0.0" @@ -2302,10 +2518,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" [[package]] -name = "url" -version = "2.5.2" +name = "untrusted" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22784dbdf76fdde8af1aeda5622b546b422b6fc585325248a2bf9f5e41e94d6c" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d157f1b96d14500ffdc1f10ba712e780825526c03d9a49b4d0324b0d9113ada" dependencies = [ "form_urlencoded", "idna", @@ -2314,9 +2536,21 @@ dependencies = [ [[package]] name = "urlencoding" -version = "2.1.2" +version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8db7427f936968176eaa7cdf81b7f98b980b18495ec28f1b5791ac3bfe3eea9" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + +[[package]] +name = "utf16_iter" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8232dd3cdaed5356e0f716d285e4b40b932ac434100fe9b7e0e8e935b9e6246" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" [[package]] name = "utf8parse" @@ -2385,7 +2619,7 @@ dependencies = [ "once_cell", "proc-macro2", "quote", - "syn 2.0.85", + "syn 2.0.87", "wasm-bindgen-shared", ] @@ -2419,7 +2653,7 @@ checksum = "26c6ab57572f7a24a4985830b120de1594465e5d500f24afe89e16b4e833ef68" dependencies = [ "proc-macro2", "quote", - "syn 2.0.85", + "syn 2.0.87", "wasm-bindgen-backend", "wasm-bindgen-shared", ] @@ -2472,12 +2706,33 @@ dependencies = [ ] [[package]] -name = "windows-sys" -version = "0.45.0" +name = "windows-registry" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +checksum = "e400001bb720a623c1c69032f8e3e4cf09984deec740f007dd2b03ec864804b0" dependencies = [ - "windows-targets 0.42.2", + "windows-result", + "windows-strings", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-result" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-strings" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10" +dependencies = [ + "windows-result", + "windows-targets 0.52.6", ] [[package]] @@ -2507,21 +2762,6 @@ dependencies = [ "windows-targets 0.52.6", ] -[[package]] -name = "windows-targets" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" -dependencies = [ - "windows_aarch64_gnullvm 0.42.2", - "windows_aarch64_msvc 0.42.2", - "windows_i686_gnu 0.42.2", - "windows_i686_msvc 0.42.2", - "windows_x86_64_gnu 0.42.2", - "windows_x86_64_gnullvm 0.42.2", - "windows_x86_64_msvc 0.42.2", -] - [[package]] name = "windows-targets" version = "0.48.5" @@ -2553,12 +2793,6 @@ dependencies = [ "windows_x86_64_msvc 0.52.6", ] -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" - [[package]] name = "windows_aarch64_gnullvm" version = "0.48.5" @@ -2571,12 +2805,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" -[[package]] -name = "windows_aarch64_msvc" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" - [[package]] name = "windows_aarch64_msvc" version = "0.48.5" @@ -2589,12 +2817,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" -[[package]] -name = "windows_i686_gnu" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" - [[package]] name = "windows_i686_gnu" version = "0.48.5" @@ -2613,12 +2835,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" -[[package]] -name = "windows_i686_msvc" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" - [[package]] name = "windows_i686_msvc" version = "0.48.5" @@ -2631,12 +2847,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" -[[package]] -name = "windows_x86_64_gnu" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" - [[package]] name = "windows_x86_64_gnu" version = "0.48.5" @@ -2649,12 +2859,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" - [[package]] name = "windows_x86_64_gnullvm" version = "0.48.5" @@ -2667,12 +2871,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" -[[package]] -name = "windows_x86_64_msvc" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" - [[package]] name = "windows_x86_64_msvc" version = "0.48.5" @@ -2687,36 +2885,54 @@ checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" [[package]] name = "winnow" -version = "0.5.40" +version = "0.6.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876" +checksum = "36c1fec1a2bb5866f07c25f68c26e565c4c200aebb96d7e55710c19d3e8ac49b" dependencies = [ "memchr", ] [[package]] -name = "winreg" -version = "0.10.1" +name = "write16" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "80d0f4e272c85def139476380b12f9ac60926689dd2e01d4923222f40580869d" -dependencies = [ - "winapi", -] +checksum = "d1890f4022759daae28ed4fe62859b1236caebfc61ede2f63ed4e695f3f6d936" [[package]] -name = "yaml-rust" -version = "0.4.5" +name = "writeable" +version = "0.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56c1936c4cc7a1c9ab21a1ebb602eb942ba868cbd44a99cb7cdc5892335e1c85" -dependencies = [ - "linked-hash-map", -] +checksum = "1e9df38ee2d2c3c5948ea468a8406ff0db0b29ae1ffde1bcf20ef305bcc95c51" [[package]] name = "yansi" -version = "0.5.1" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09041cd90cf85f7f8b2df60c646f853b7f535ce68f85244eb6731cf89fa498ec" +checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" + +[[package]] +name = "yoke" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c5b1314b079b0930c31e3af543d8ee1757b1951ae1e1565ec704403a7240ca5" +dependencies = [ + "serde", + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28cc31741b18cb6f1d5ff12f5b7523e3d6eb0852bbbad19d73905511d9849b95" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.87", + "synstructure", +] [[package]] name = "zerocopy" @@ -2736,5 +2952,54 @@ checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.85", + "syn 2.0.87", +] + +[[package]] +name = "zerofrom" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91ec111ce797d0e0784a1116d0ddcdbea84322cd79e5d5ad173daeba4f93ab55" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ea7b4a3637ea8669cedf0f1fd5c286a17f3de97b8dd5a70a6c167a1730e63a5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.87", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde" + +[[package]] +name = "zerovec" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa2b893d79df23bfb12d5461018d408ea19dfafe76c2c7ef6d4eba614f8ff079" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6eafa6dfb17584ea3e2bd6e76e0cc15ad7af12b09abdd1ca55961bed9b1063c6" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.87", ] diff --git a/Cargo.toml b/Cargo.toml index 1d9ca4d..dd0ff7a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,14 +21,14 @@ chrono = { version = "0.4.38", features = ["serde"] } confy = { version = "0.6.0", default-features = false, features = [ "yaml_conf", ] } -crossterm = "0.27.0" +crossterm = "0.28.1" derivative = "2.2.0" -human-panic = "1.1.3" +human-panic = "2.0.2" indoc = "2.0.0" log = "0.4.17" log4rs = { version = "1.2.0", features = ["file_appender"] } regex = "1.11.1" -reqwest = { version = "0.11.14", features = ["json"] } +reqwest = { version = "0.12.9", features = ["json"] } serde_yaml = "0.9.16" serde_json = "1.0.91" serde = { version = "1.0.214", features = ["derive"] } @@ -36,7 +36,7 @@ strum = { version = "0.26.3", features = ["derive"] } strum_macros = "0.26.4" tokio = { version = "1.36.0", features = ["full"] } tokio-util = "0.7.8" -ratatui = { version = "0.28.0", features = ["all-widgets"] } +ratatui = { version = "0.29.0", features = ["all-widgets"] } urlencoding = "2.1.2" clap = { version = "4.5.20", features = ["derive", "cargo", "env"] } clap_complete = "4.5.33" @@ -45,13 +45,14 @@ ctrlc = "3.4.5" colored = "2.1.0" async-trait = "0.1.83" dirs-next = "2.0.0" +managarr-tree-widget = { git = "https://github.com/Dark-Alex-17/managarr-tree-widget.git" } [dev-dependencies] assert_cmd = "2.0.16" mockall = "0.13.0" mockito = "1.0.0" pretty_assertions = "1.3.0" -rstest = "0.18.2" +rstest = "0.23.0" [dev-dependencies.cargo-husky] version = "1" diff --git a/src/models/mod.rs b/src/models/mod.rs index 0845624..14abfbb 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -13,6 +13,7 @@ pub mod servarr_data; pub mod sonarr_models; pub mod stateful_list; pub mod stateful_table; +pub mod stateful_tree; #[cfg(test)] #[path = "model_tests.rs"] diff --git a/src/models/stateful_tree.rs b/src/models/stateful_tree.rs new file mode 100644 index 0000000..6837777 --- /dev/null +++ b/src/models/stateful_tree.rs @@ -0,0 +1,62 @@ +use managarr_tree_widget::{TreeItem, TreeState}; +use ratatui::text::ToText; + +use super::Scrollable; +use core::hash::Hash; +use std::fmt::{Debug, Display}; + +#[cfg(test)] +#[path = "stateful_tree_tests.rs"] +mod stateful_tree_tests; + +#[derive(Default)] +pub struct StatefulTree +where + T: ToText + Hash + Clone + PartialEq + Eq + Debug + Default + Display, +{ + pub state: TreeState, + pub items: Vec>, +} + +impl StatefulTree +where + T: ToText + Hash + Clone + PartialEq + Eq + Debug + Default + Display, +{ + pub fn set_items(&mut self, items: Vec>) { + self.items = items; + } + + pub fn current_selection(&self) -> Option<&T> { + self + .state + .flatten(&self.items) + .into_iter() + .find(|i| self.state.selected() == i.identifier) + .map(|item| item.item.content()) + } + + pub fn is_empty(&self) -> bool { + self.items.is_empty() + } +} + +impl Scrollable for StatefulTree +where + T: ToText + Hash + Clone + PartialEq + Eq + Debug + Default + Display, +{ + fn scroll_down(&mut self) { + self.state.key_down(); + } + + fn scroll_up(&mut self) { + self.state.key_up(); + } + + fn scroll_to_top(&mut self) { + self.state.select_first(); + } + + fn scroll_to_bottom(&mut self) { + self.state.select_last(); + } +} diff --git a/src/models/stateful_tree_tests.rs b/src/models/stateful_tree_tests.rs new file mode 100644 index 0000000..cd63da4 --- /dev/null +++ b/src/models/stateful_tree_tests.rs @@ -0,0 +1,177 @@ +#[cfg(test)] +mod tests { + use std::hash::{DefaultHasher, Hash, Hasher}; + + use crate::models::stateful_tree::StatefulTree; + use managarr_tree_widget::{Tree, TreeItem, TreeState}; + use pretty_assertions::{assert_eq, assert_str_eq}; + use ratatui::buffer::Buffer; + use ratatui::layout::Rect; + use ratatui::widgets::StatefulWidget; + use crate::models::Scrollable; + + #[test] + fn test_stateful_tree_scrolling_on_empty_tree_performs_no_op() { + let mut stateful_tree: StatefulTree<&str> = StatefulTree::default(); + render(&mut stateful_tree.state, &stateful_tree.items); + stateful_tree.state.key_down(); + render(&mut stateful_tree.state, &stateful_tree.items); + + assert_eq!(stateful_tree.state.selected(), Vec::::new()); + + stateful_tree.scroll_up(); + render(&mut stateful_tree.state, &stateful_tree.items); + + assert_eq!(stateful_tree.state.selected(), Vec::::new()); + + stateful_tree.scroll_down(); + render(&mut stateful_tree.state, &stateful_tree.items); + + assert_eq!(stateful_tree.state.selected(), Vec::::new()); + + stateful_tree.scroll_to_top(); + render(&mut stateful_tree.state, &stateful_tree.items); + + assert_eq!(stateful_tree.state.selected(), Vec::::new()); + + stateful_tree.scroll_to_bottom(); + render(&mut stateful_tree.state, &stateful_tree.items); + } + + #[test] + fn test_stateful_tree_scroll() { + let mut stateful_tree = create_test_stateful_tree(); + let hash = |s: &str| { + let mut hasher = DefaultHasher::new(); + s.hash(&mut hasher); + hasher.finish() + }; + render(&mut stateful_tree.state, &stateful_tree.items); + stateful_tree.scroll_down(); + render(&mut stateful_tree.state, &stateful_tree.items); + + assert_eq!(stateful_tree.state.selected(), &[hash("Test 1")]); + + stateful_tree.scroll_down(); + render(&mut stateful_tree.state, &stateful_tree.items); + + assert_eq!(stateful_tree.state.selected(), &[hash("Test 2")]); + + stateful_tree.scroll_down(); + render(&mut stateful_tree.state, &stateful_tree.items); + + assert_eq!(stateful_tree.state.selected(), &[hash("Test 3")]); + + stateful_tree.scroll_down(); + render(&mut stateful_tree.state, &stateful_tree.items); + + assert_eq!(stateful_tree.state.selected(), &[hash("Test 3")]); + + stateful_tree.scroll_up(); + render(&mut stateful_tree.state, &stateful_tree.items); + + assert_eq!(stateful_tree.state.selected(), &[hash("Test 2")]); + + stateful_tree.scroll_up(); + render(&mut stateful_tree.state, &stateful_tree.items); + + assert_eq!(stateful_tree.state.selected(), &[hash("Test 1")]); + + stateful_tree.scroll_to_bottom(); + render(&mut stateful_tree.state, &stateful_tree.items); + + assert_eq!(stateful_tree.state.selected(), &[hash("Test 3")]); + + stateful_tree.scroll_to_top(); + render(&mut stateful_tree.state, &stateful_tree.items); + + assert_eq!(stateful_tree.state.selected(), &[hash("Test 1")]); + } + + #[test] + fn test_stateful_tree_set_items() { + let items_vec = vec![ + TreeItem::new_leaf("Test 1"), + TreeItem::new_leaf("Test 2"), + TreeItem::new_leaf("Test 3"), + ]; + let hash = |s: &str| { + let mut hasher = DefaultHasher::new(); + s.hash(&mut hasher); + hasher.finish() + }; + let mut stateful_tree: StatefulTree<&str> = StatefulTree::default(); + + stateful_tree.set_items(items_vec.clone()); + render(&mut stateful_tree.state, &stateful_tree.items); + stateful_tree.state.key_down(); + render(&mut stateful_tree.state, &stateful_tree.items); + + assert_eq!(stateful_tree.state.selected(), &[hash("Test 1")]); + + stateful_tree.state.key_down(); + render(&mut stateful_tree.state, &stateful_tree.items); + stateful_tree.set_items(items_vec.clone()); + render(&mut stateful_tree.state, &stateful_tree.items); + + assert_eq!(stateful_tree.state.selected(), &[hash("Test 2")]); + + stateful_tree.state.key_down(); + render(&mut stateful_tree.state, &stateful_tree.items); + stateful_tree.set_items(items_vec); + render(&mut stateful_tree.state, &stateful_tree.items); + + assert_eq!(stateful_tree.state.selected(), &[hash("Test 3")]); + } + + #[test] + fn test_stateful_tree_current_selection() { + let mut stateful_tree = create_test_stateful_tree(); + render(&mut stateful_tree.state, &stateful_tree.items); + stateful_tree.state.key_down(); + render(&mut stateful_tree.state, &stateful_tree.items); + + let current_selection = stateful_tree.current_selection(); + + assert!(current_selection.is_some()); + assert_str_eq!(current_selection.unwrap(), stateful_tree.items[0].content()); + + stateful_tree.state.key_down(); + render(&mut stateful_tree.state, &stateful_tree.items); + let current_selection = stateful_tree.current_selection(); + + assert!(current_selection.is_some()); + assert_str_eq!(current_selection.unwrap(), stateful_tree.items[1].content()); + } + + #[test] + fn test_stateful_tree_is_empty() { + let mut stateful_tree = create_test_stateful_tree(); + render(&mut stateful_tree.state, &stateful_tree.items); + + assert!(!stateful_tree.is_empty()); + + stateful_tree = StatefulTree::default(); + render(&mut stateful_tree.state, &stateful_tree.items); + + assert!(stateful_tree.is_empty()); + } + + fn render(state: &mut TreeState, items: &[TreeItem<&str>]) { + let tree = Tree::new(items).unwrap(); + let area = Rect::new(0, 0, 10, 4); + let mut buffer = Buffer::empty(area); + StatefulWidget::render(tree, area, &mut buffer, state); + } + + fn create_test_stateful_tree() -> StatefulTree<&'static str> { + let mut stateful_tree = StatefulTree::default(); + stateful_tree.set_items(vec![ + TreeItem::new_leaf("Test 1"), + TreeItem::new_leaf("Test 2"), + TreeItem::new_leaf("Test 3"), + ]); + + stateful_tree + } +} diff --git a/src/ui/widgets/managarr_table.rs b/src/ui/widgets/managarr_table.rs index 7d348f5..eb9b5c9 100644 --- a/src/ui/widgets/managarr_table.rs +++ b/src/ui/widgets/managarr_table.rs @@ -146,7 +146,7 @@ where if self.highlight_rows { table = table - .highlight_style(Style::new().highlight()) + .row_highlight_style(Style::new().highlight()) .highlight_symbol(HIGHLIGHT_SYMBOL); } From 295cd56a1fb91cd9becafcf9c4d58360a610c312 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Fri, 15 Nov 2024 12:48:35 -0700 Subject: [PATCH 006/119] feat(models): Added the Episode model to Sonarr models --- src/models/servarr_data/sonarr/sonarr_data.rs | 5 +++- src/models/sonarr_models.rs | 30 +++++++++++++++++++ src/models/sonarr_models_tests.rs | 27 +++++++++++++++-- 3 files changed, 59 insertions(+), 3 deletions(-) diff --git a/src/models/servarr_data/sonarr/sonarr_data.rs b/src/models/servarr_data/sonarr/sonarr_data.rs index 4707991..acd61a5 100644 --- a/src/models/servarr_data/sonarr/sonarr_data.rs +++ b/src/models/servarr_data/sonarr/sonarr_data.rs @@ -2,9 +2,10 @@ use chrono::{DateTime, Utc}; use strum::EnumIter; use crate::models::{ - sonarr_models::{BlocklistItem, Series}, + sonarr_models::{BlocklistItem, Episode, Series}, stateful_list::StatefulList, stateful_table::StatefulTable, + stateful_tree::StatefulTree, HorizontallyScrollableText, Route, }; @@ -18,6 +19,7 @@ pub struct SonarrData { pub series: StatefulTable, pub blocklist: StatefulTable, pub logs: StatefulList, + pub episodes: StatefulTree, } impl Default for SonarrData { @@ -28,6 +30,7 @@ impl Default for SonarrData { series: StatefulTable::default(), blocklist: StatefulTable::default(), logs: StatefulList::default(), + episodes: StatefulTree::default(), } } } diff --git a/src/models/sonarr_models.rs b/src/models/sonarr_models.rs index d3ea7c9..b11fe77 100644 --- a/src/models/sonarr_models.rs +++ b/src/models/sonarr_models.rs @@ -37,6 +37,34 @@ pub struct BlocklistResponse { pub records: Vec, } +#[derive(Default, Serialize, Deserialize, Hash, Debug, Clone, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct Episode { + #[serde(deserialize_with = "super::from_i64")] + pub id: i64, + #[serde(deserialize_with = "super::from_i64")] + pub series_id: i64, + #[serde(deserialize_with = "super::from_i64")] + pub tvdb_id: i64, + #[serde(deserialize_with = "super::from_i64")] + pub episode_file_id: i64, + #[serde(deserialize_with = "super::from_i64")] + pub season_number: i64, + #[serde(deserialize_with = "super::from_i64")] + pub episode_number: i64, + pub title: Option, + pub air_date_utc: Option>, + pub overview: Option, + pub has_file: bool, + pub monitored: bool, +} + +impl Display for Episode { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.title.as_ref().unwrap_or(&String::new())) + } +} + #[derive(Serialize, Deserialize, Default, Debug, Clone, PartialEq, Eq, Ord, PartialOrd)] pub struct Language { pub name: String, @@ -229,6 +257,7 @@ impl SeriesStatus { #[allow(clippy::large_enum_variant)] pub enum SonarrSerdeable { Value(Value), + Episodes(Vec), SeriesVec(Vec), SystemStatus(SystemStatus), BlocklistResponse(BlocklistResponse), @@ -250,6 +279,7 @@ impl From<()> for SonarrSerdeable { serde_enum_from!( SonarrSerdeable { Value(Value), + Episodes(Vec), SeriesVec(Vec), SystemStatus(SystemStatus), BlocklistResponse(BlocklistResponse), diff --git a/src/models/sonarr_models_tests.rs b/src/models/sonarr_models_tests.rs index 7b389c1..e4c659a 100644 --- a/src/models/sonarr_models_tests.rs +++ b/src/models/sonarr_models_tests.rs @@ -5,12 +5,23 @@ mod tests { use crate::models::{ sonarr_models::{ - BlocklistItem, BlocklistResponse, Log, LogResponse, Series, SeriesStatus, SeriesType, - SonarrSerdeable, SystemStatus, + BlocklistItem, BlocklistResponse, Episode, Log, LogResponse, Series, SeriesStatus, + SeriesType, SonarrSerdeable, SystemStatus, }, Serdeable, }; + #[test] + fn test_episode_display() { + let episode = Episode { + title: Some("Test Title".to_owned()), + ..Episode::default() + }; + + assert_str_eq!(Episode::default().to_string(), ""); + assert_str_eq!(episode.to_string(), "Test Title"); + } + #[test] fn test_series_status_display() { assert_str_eq!(SeriesStatus::Continuing.to_string(), "continuing"); @@ -66,6 +77,18 @@ mod tests { assert_eq!(sonarr_serdeable, SonarrSerdeable::Value(value)); } + #[test] + fn test_sonarr_serdeable_from_episodes() { + let episodes = vec![Episode { + id: 1, + ..Episode::default() + }]; + + let sonarr_serdeable: SonarrSerdeable = episodes.clone().into(); + + assert_eq!(sonarr_serdeable, SonarrSerdeable::Episodes(episodes)); + } + #[test] fn test_sonarr_serdeable_from_series() { let series = vec![Series { From 6dffc90e9265c215091ece78c5caecb94fe899ff Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Fri, 15 Nov 2024 14:57:19 -0700 Subject: [PATCH 007/119] feat(sonarr_network): Added support for fetching episodes for a specified series to the network events --- Cargo.lock | 2 +- src/models/sonarr_models.rs | 4 +- src/models/stateful_tree.rs | 4 +- src/network/sonarr_network.rs | 75 +++++++- src/network/sonarr_network_tests.rs | 270 +++++++++++++++++++++++++++- 5 files changed, 348 insertions(+), 7 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 7f464e2..93eb84d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1314,7 +1314,7 @@ dependencies = [ [[package]] name = "managarr-tree-widget" version = "0.24.0" -source = "git+https://github.com/Dark-Alex-17/managarr-tree-widget.git#6baef469c8d20f7f1e567edba9886401ea9baa7c" +source = "git+https://github.com/Dark-Alex-17/managarr-tree-widget.git#b0d8d9f0bfdbb4f4a43f9a59806f8e84bbaf4e30" dependencies = [ "ratatui", "unicode-width 0.2.0", diff --git a/src/models/sonarr_models.rs b/src/models/sonarr_models.rs index b11fe77..afdebaa 100644 --- a/src/models/sonarr_models.rs +++ b/src/models/sonarr_models.rs @@ -279,11 +279,11 @@ impl From<()> for SonarrSerdeable { serde_enum_from!( SonarrSerdeable { Value(Value), - Episodes(Vec), + Episodes(Vec), SeriesVec(Vec), SystemStatus(SystemStatus), BlocklistResponse(BlocklistResponse), - LogResponse(LogResponse), + LogResponse(LogResponse), } ); diff --git a/src/models/stateful_tree.rs b/src/models/stateful_tree.rs index 6837777..e33b516 100644 --- a/src/models/stateful_tree.rs +++ b/src/models/stateful_tree.rs @@ -12,7 +12,7 @@ mod stateful_tree_tests; #[derive(Default)] pub struct StatefulTree where - T: ToText + Hash + Clone + PartialEq + Eq + Debug + Default + Display, + T: ToText + Hash + Clone + PartialEq + Eq + Debug + Default + Display + PartialEq + Eq, { pub state: TreeState, pub items: Vec>, @@ -20,7 +20,7 @@ where impl StatefulTree where - T: ToText + Hash + Clone + PartialEq + Eq + Debug + Default + Display, + T: ToText + Hash + Clone + PartialEq + Eq + Debug + Default + Display + PartialEq + Eq, { pub fn set_items(&mut self, items: Vec>) { self.items = items; diff --git a/src/network/sonarr_network.rs b/src/network/sonarr_network.rs index 2e87e22..252ab08 100644 --- a/src/network/sonarr_network.rs +++ b/src/network/sonarr_network.rs @@ -1,11 +1,16 @@ +use std::collections::BTreeMap; + use anyhow::Result; use log::info; +use managarr_tree_widget::TreeItem; use serde_json::{json, Value}; use crate::{ models::{ servarr_data::sonarr::sonarr_data::ActiveSonarrBlock, - sonarr_models::{BlocklistResponse, LogResponse, Series, SonarrSerdeable, SystemStatus}, + sonarr_models::{ + BlocklistResponse, Episode, LogResponse, Series, SonarrSerdeable, SystemStatus, + }, HorizontallyScrollableText, Route, Scrollable, }, network::RequestMethod, @@ -21,6 +26,7 @@ pub enum SonarrEvent { ClearBlocklist, DeleteBlocklistItem(Option), GetBlocklist, + GetEpisodes(Option), GetLogs(Option), GetStatus, HealthCheck, @@ -33,6 +39,7 @@ impl NetworkResource for SonarrEvent { SonarrEvent::ClearBlocklist => "/blocklist/bulk", SonarrEvent::DeleteBlocklistItem(_) => "/blocklist", SonarrEvent::GetBlocklist => "/blocklist?page=1&pageSize=10000", + SonarrEvent::GetEpisodes(_) => "/episode", SonarrEvent::GetLogs(_) => "/log", SonarrEvent::GetStatus => "/system/status", SonarrEvent::HealthCheck => "/health", @@ -62,6 +69,10 @@ impl<'a, 'b> Network<'a, 'b> { .await .map(SonarrSerdeable::from), SonarrEvent::GetBlocklist => self.get_sonarr_blocklist().await.map(SonarrSerdeable::from), + SonarrEvent::GetEpisodes(series_id) => self + .get_episodes(series_id) + .await + .map(SonarrSerdeable::from), SonarrEvent::GetLogs(events) => self .get_sonarr_logs(events) .await @@ -175,6 +186,51 @@ impl<'a, 'b> Network<'a, 'b> { .await } + async fn get_episodes(&mut self, series_id: Option) -> Result> { + let event = SonarrEvent::GetEpisodes(series_id); + let (id, series_id_param) = self.extract_series_id(series_id).await; + info!("Fetching episodes for Sonarr series with ID: {id}"); + + let request_props = self + .request_props_from( + event, + RequestMethod::Get, + None::<()>, + None, + Some(series_id_param), + ) + .await; + + self + .handle_request::<(), Vec>(request_props, |mut episode_vec, mut app| { + episode_vec.sort_by(|a, b| a.id.cmp(&b.id)); + let mut seasons = BTreeMap::new(); + + for episode in episode_vec { + seasons + .entry(episode.season_number) + .or_insert_with(Vec::new) + .push(episode); + } + + let tree = seasons + .into_iter() + .map(|(season, episodes_vec)| { + let marker_episode = Episode { + title: Some(format!("Season {season}")), + ..Episode::default() + }; + let children = episodes_vec.into_iter().map(TreeItem::new_leaf).collect(); + + TreeItem::new(marker_episode, children).expect("All item identifiers must be unique") + }) + .collect(); + + app.data.sonarr_data.episodes.set_items(tree); + }) + .await + } + async fn get_sonarr_logs(&mut self, events: Option) -> Result { info!("Fetching Sonarr logs"); let event = SonarrEvent::GetLogs(events); @@ -259,4 +315,21 @@ impl<'a, 'b> Network<'a, 'b> { }) .await } + + async fn extract_series_id(&mut self, series_id: Option) -> (i64, String) { + let series_id = if let Some(id) = series_id { + id + } else { + self + .app + .lock() + .await + .data + .sonarr_data + .series + .current_selection() + .id + }; + (series_id, format!("seriesId={series_id}")) + } } diff --git a/src/network/sonarr_network_tests.rs b/src/network/sonarr_network_tests.rs index 84159ff..6b8c0d3 100644 --- a/src/network/sonarr_network_tests.rs +++ b/src/network/sonarr_network_tests.rs @@ -1,17 +1,23 @@ #[cfg(test)] mod test { + use std::sync::Arc; + use chrono::{DateTime, Utc}; + use managarr_tree_widget::TreeItem; use pretty_assertions::{assert_eq, assert_str_eq}; use reqwest::Client; use rstest::rstest; use serde_json::json; use serde_json::{Number, Value}; + use tokio::sync::Mutex; use tokio_util::sync::CancellationToken; + use crate::app::App; use crate::models::servarr_data::sonarr::sonarr_data::ActiveSonarrBlock; - use crate::models::sonarr_models::{BlocklistItem, Language, LogResponse}; + use crate::models::sonarr_models::{BlocklistItem, Episode, Language, LogResponse}; use crate::models::sonarr_models::{BlocklistResponse, Quality}; use crate::models::sonarr_models::{QualityWrapper, SystemStatus}; + use crate::models::stateful_table::StatefulTable; use crate::models::HorizontallyScrollableText; use crate::models::{sonarr_models::SonarrSerdeable, stateful_table::SortOption}; @@ -80,6 +86,7 @@ mod test { #[case(SonarrEvent::DeleteBlocklistItem(None), "/blocklist")] #[case(SonarrEvent::HealthCheck, "/health")] #[case(SonarrEvent::GetBlocklist, "/blocklist?page=1&pageSize=10000")] + #[case(SonarrEvent::GetEpisodes(None), "/episode")] #[case(SonarrEvent::GetLogs(Some(500)), "/log")] #[case(SonarrEvent::GetStatus, "/system/status")] fn test_resource(#[case] event: SonarrEvent, #[case] expected_uri: String) { @@ -278,6 +285,190 @@ mod test { async_server.assert_async().await; } + #[tokio::test] + async fn test_handle_get_episodes_event() { + let episodes_json = json!([ + { + "id": 2, + "seriesId": 1, + "tvdbId": 1234, + "episodeFileId": 2, + "seasonNumber": 2, + "episodeNumber": 2, + "title": "Something cool", + "airDateUtc": "2024-02-10T07:28:45Z", + "overview": "Okay so this one time at band camp...", + "hasFile": true, + "monitored": true + }, + { + "id": 1, + "seriesId": 1, + "tvdbId": 1234, + "episodeFileId": 1, + "seasonNumber": 1, + "episodeNumber": 1, + "title": "Something cool", + "airDateUtc": "2024-02-10T07:28:45Z", + "overview": "Okay so this one time at band camp...", + "hasFile": true, + "monitored": true + } + ]); + let marker_episode_1 = Episode { + title: Some("Season 1".to_owned()), + ..Episode::default() + }; + let marker_episode_2 = Episode { + title: Some("Season 2".to_owned()), + ..Episode::default() + }; + let episode_1 = episode(); + let episode_2 = Episode { + id: 2, + episode_file_id: 2, + season_number: 2, + episode_number: 2, + ..episode() + }; + let expected_episodes = vec![episode_2.clone(), episode_1.clone()]; + let expected_tree = vec![ + TreeItem::new( + marker_episode_1, + vec![TreeItem::new_leaf(episode_1.clone())], + ) + .unwrap(), + TreeItem::new( + marker_episode_2, + vec![TreeItem::new_leaf(episode_2.clone())], + ) + .unwrap(), + ]; + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(episodes_json), + None, + SonarrEvent::GetEpisodes(None), + None, + Some("seriesId=1"), + ) + .await; + app_arc + .lock() + .await + .data + .sonarr_data + .series + .set_items(vec![Series { + id: 1, + ..Series::default() + }]); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + if let SonarrSerdeable::Episodes(episodes) = network + .handle_sonarr_event(SonarrEvent::GetEpisodes(None)) + .await + .unwrap() + { + async_server.assert_async().await; + assert_eq!( + app_arc.lock().await.data.sonarr_data.episodes.items, + expected_tree + ); + assert_eq!(episodes, expected_episodes); + } + } + + #[tokio::test] + async fn test_handle_get_episodes_event_uses_provided_series_id() { + let episodes_json = json!([ + { + "id": 2, + "seriesId": 2, + "tvdbId": 1234, + "episodeFileId": 2, + "seasonNumber": 2, + "episodeNumber": 2, + "title": "Something cool", + "airDateUtc": "2024-02-10T07:28:45Z", + "overview": "Okay so this one time at band camp...", + "hasFile": true, + "monitored": true + }, + { + "id": 1, + "seriesId": 2, + "tvdbId": 1234, + "episodeFileId": 1, + "seasonNumber": 1, + "episodeNumber": 1, + "title": "Something cool", + "airDateUtc": "2024-02-10T07:28:45Z", + "overview": "Okay so this one time at band camp...", + "hasFile": true, + "monitored": true + } + ]); + let marker_episode_1 = Episode { + title: Some("Season 1".to_owned()), + ..Episode::default() + }; + let marker_episode_2 = Episode { + title: Some("Season 2".to_owned()), + ..Episode::default() + }; + let episode_1 = Episode { + series_id: 2, + ..episode() + }; + let episode_2 = Episode { + id: 2, + episode_file_id: 2, + season_number: 2, + episode_number: 2, + series_id: 2, + ..episode() + }; + let expected_episodes = vec![episode_2.clone(), episode_1.clone()]; + let expected_tree = vec![ + TreeItem::new( + marker_episode_1, + vec![TreeItem::new_leaf(episode_1.clone())], + ) + .unwrap(), + TreeItem::new( + marker_episode_2, + vec![TreeItem::new_leaf(episode_2.clone())], + ) + .unwrap(), + ]; + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(episodes_json), + None, + SonarrEvent::GetEpisodes(None), + None, + Some("seriesId=2"), + ) + .await; + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + if let SonarrSerdeable::Episodes(episodes) = network + .handle_sonarr_event(SonarrEvent::GetEpisodes(Some(2))) + .await + .unwrap() + { + async_server.assert_async().await; + assert_eq!( + app_arc.lock().await.data.sonarr_data.episodes.items, + expected_tree + ); + assert_eq!(episodes, expected_episodes); + } + } + #[tokio::test] async fn test_handle_get_sonarr_logs_event() { let expected_logs = vec![ @@ -591,6 +782,65 @@ mod test { } } + #[tokio::test] + async fn test_extract_series_id() { + let app_arc = Arc::new(Mutex::new(App::default())); + app_arc + .lock() + .await + .data + .sonarr_data + .series + .set_items(vec![Series { + id: 1, + ..Series::default() + }]); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + let (id, series_id_param) = network.extract_series_id(None).await; + + assert_eq!(id, 1); + assert_str_eq!(series_id_param, "seriesId=1"); + } + + #[tokio::test] + async fn test_extract_series_id_uses_provided_id() { + let app_arc = Arc::new(Mutex::new(App::default())); + app_arc + .lock() + .await + .data + .sonarr_data + .series + .set_items(vec![Series { + id: 1, + ..Series::default() + }]); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + let (id, series_id_param) = network.extract_series_id(Some(2)).await; + + assert_eq!(id, 2); + assert_str_eq!(series_id_param, "seriesId=2"); + } + + #[tokio::test] + async fn test_extract_series_id_filtered_series() { + let app_arc = Arc::new(Mutex::new(App::default())); + let mut filtered_series = StatefulTable::default(); + filtered_series.set_filtered_items(vec![Series { + id: 1, + ..Series::default() + }]); + app_arc.lock().await.data.sonarr_data.series = filtered_series; + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + let (id, series_id_param) = network.extract_series_id(None).await; + + assert_eq!(id, 1); + assert_str_eq!(series_id_param, "seriesId=1"); + } + fn blocklist_item() -> BlocklistItem { BlocklistItem { id: 1, @@ -606,6 +856,24 @@ mod test { } } + fn episode() -> Episode { + Episode { + id: 1, + series_id: 1, + tvdb_id: 1234, + episode_file_id: 1, + season_number: 1, + episode_number: 1, + title: Some("Something cool".to_owned()), + air_date_utc: Some(DateTime::from( + DateTime::parse_from_rfc3339("2024-02-10T07:28:45Z").unwrap(), + )), + overview: Some("Okay so this one time at band camp...".to_owned()), + has_file: true, + monitored: true, + } + } + fn language() -> Language { Language { name: "English".to_owned(), From 1fe95d057bcb80798204430f63fba49dd4a75abe Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Fri, 15 Nov 2024 15:14:34 -0700 Subject: [PATCH 008/119] feat(cli): Sonarr CLI support for fetching all episodes for a given series --- src/cli/radarr/list_command_handler_tests.rs | 4 +- src/cli/sonarr/list_command_handler.rs | 12 +++++ src/cli/sonarr/list_command_handler_tests.rs | 51 ++++++++++++++++++++ src/main.rs | 10 ++-- 4 files changed, 68 insertions(+), 9 deletions(-) diff --git a/src/cli/radarr/list_command_handler_tests.rs b/src/cli/radarr/list_command_handler_tests.rs index 2252d03..f922de9 100644 --- a/src/cli/radarr/list_command_handler_tests.rs +++ b/src/cli/radarr/list_command_handler_tests.rs @@ -80,8 +80,8 @@ mod tests { assert!(result.is_ok()); - if let Some(Command::Radarr(RadarrCommand::List(refresh_command))) = result.unwrap().command { - assert_eq!(refresh_command, expected_args); + if let Some(Command::Radarr(RadarrCommand::List(credits_command))) = result.unwrap().command { + assert_eq!(credits_command, expected_args); } } diff --git a/src/cli/sonarr/list_command_handler.rs b/src/cli/sonarr/list_command_handler.rs index 00756e3..227ba9a 100644 --- a/src/cli/sonarr/list_command_handler.rs +++ b/src/cli/sonarr/list_command_handler.rs @@ -21,6 +21,15 @@ mod list_command_handler_tests; pub enum SonarrListCommand { #[command(about = "List all items in the Sonarr blocklist")] Blocklist, + #[command(about = "List the episodes for the series with the given ID")] + Episodes { + #[arg( + long, + help = "The Sonarr ID of the series whose episodes you wish to fetch", + required = true + )] + series_id: i64, + }, #[command(about = "Fetch Sonarr logs")] Logs { #[arg(long, help = "How many log events to fetch", default_value_t = 500)] @@ -65,6 +74,9 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, SonarrListCommand> for SonarrListCommandH SonarrListCommand::Blocklist => { execute_network_event!(self, SonarrEvent::GetBlocklist); } + SonarrListCommand::Episodes { series_id } => { + execute_network_event!(self, SonarrEvent::GetEpisodes(Some(series_id))); + } SonarrListCommand::Logs { events, output_in_log_format, diff --git a/src/cli/sonarr/list_command_handler_tests.rs b/src/cli/sonarr/list_command_handler_tests.rs index b7a5c9a..b004b0a 100644 --- a/src/cli/sonarr/list_command_handler_tests.rs +++ b/src/cli/sonarr/list_command_handler_tests.rs @@ -31,6 +31,17 @@ mod tests { assert!(result.is_ok()); } + #[test] + fn test_list_episodes_requires_series_id() { + let result = Cli::command().try_get_matches_from(["managarr", "sonarr", "list", "episodes"]); + + assert!(result.is_err()); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + #[test] fn test_list_logs_events_flag_requires_arguments() { let result = @@ -54,6 +65,20 @@ mod tests { assert_eq!(refresh_command, expected_args); } } + + #[test] + fn test_list_episodes_success() { + let expected_args = SonarrListCommand::Episodes { series_id: 1 }; + let result = + Cli::try_parse_from(["managarr", "sonarr", "list", "episodes", "--series-id", "1"]); + + assert!(result.is_ok()); + + if let Some(Command::Sonarr(SonarrCommand::List(episodes_command))) = result.unwrap().command + { + assert_eq!(episodes_command, expected_args); + } + } } mod handler { @@ -102,6 +127,32 @@ mod tests { assert!(result.is_ok()); } + #[tokio::test] + async fn test_handle_list_episodes_command() { + let expected_series_id = 1; + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::( + SonarrEvent::GetEpisodes(Some(expected_series_id)).into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Sonarr(SonarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::default())); + let list_episodes_command = SonarrListCommand::Episodes { series_id: 1 }; + + let result = + SonarrListCommandHandler::with(&app_arc, list_episodes_command, &mut mock_network) + .handle() + .await; + + assert!(result.is_ok()); + } + #[tokio::test] async fn test_handle_list_logs_command() { let expected_events = 1000; diff --git a/src/main.rs b/src/main.rs index 4123f5f..5e201f3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -21,6 +21,7 @@ use crossterm::execute; use crossterm::terminal::{ disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen, }; +use human_panic::metadata; use log::{error, warn}; use network::NetworkTrait; use ratatui::backend::CrosstermBackend; @@ -287,14 +288,9 @@ fn create_cert(cert_path: &String, servarr_name: &str) -> Certificate { #[cfg(not(debug_assertions))] fn panic_hook(info: &PanicHookInfo<'_>) { - use human_panic::{handle_dump, print_msg, Metadata}; + use human_panic::{handle_dump, print_msg}; - let meta = Metadata { - version: env!("CARGO_PKG_VERSION").into(), - name: env!("CARGO_PKG_NAME").into(), - authors: env!("CARGO_PKG_AUTHORS").replace(":", ", ").into(), - homepage: env!("CARGO_PKG_HOMEPAGE").into(), - }; + let meta = metadata!(); let file_path = handle_dump(&meta, info); disable_raw_mode().unwrap(); execute!(io::stdout(), LeaveAlternateScreen).unwrap(); From e14b7072c676a4a91ba676733fb260428cd6a21d Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Fri, 15 Nov 2024 18:19:03 -0700 Subject: [PATCH 009/119] feat(network): Added get quality profiles and get episode details events for Sonarr --- src/main.rs | 1 - src/models/servarr_data/sonarr/mod.rs | 1 + src/models/servarr_data/sonarr/modals.rs | 13 + src/models/servarr_data/sonarr/sonarr_data.rs | 20 +- .../servarr_data/sonarr/sonarr_data_tests.rs | 5 + src/models/sonarr_models.rs | 74 ++- src/models/sonarr_models_tests.rs | 31 +- src/network/radarr_network.rs | 9 +- src/network/radarr_network_tests.rs | 2 +- src/network/sonarr_network.rs | 210 +++++- src/network/sonarr_network_tests.rs | 602 +++++++++++++++++- 11 files changed, 941 insertions(+), 27 deletions(-) create mode 100644 src/models/servarr_data/sonarr/modals.rs diff --git a/src/main.rs b/src/main.rs index 5e201f3..72eeae8 100644 --- a/src/main.rs +++ b/src/main.rs @@ -21,7 +21,6 @@ use crossterm::execute; use crossterm::terminal::{ disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen, }; -use human_panic::metadata; use log::{error, warn}; use network::NetworkTrait; use ratatui::backend::CrosstermBackend; diff --git a/src/models/servarr_data/sonarr/mod.rs b/src/models/servarr_data/sonarr/mod.rs index 49bfe8e..8058f64 100644 --- a/src/models/servarr_data/sonarr/mod.rs +++ b/src/models/servarr_data/sonarr/mod.rs @@ -1 +1,2 @@ +pub mod modals; pub mod sonarr_data; diff --git a/src/models/servarr_data/sonarr/modals.rs b/src/models/servarr_data/sonarr/modals.rs new file mode 100644 index 0000000..0f00318 --- /dev/null +++ b/src/models/servarr_data/sonarr/modals.rs @@ -0,0 +1,13 @@ +use crate::models::ScrollableText; + +#[derive(Default)] +pub struct EpisodeDetailsModal { + pub episode_details: ScrollableText, + pub file_details: String, + pub audio_details: String, + pub video_details: String, + // pub episode_history: StatefulTable, + // pub episode_cast: StatefulTable, + // pub episode_crew: StatefulTable, + // pub episode_releases: StatefulTable, +} diff --git a/src/models/servarr_data/sonarr/sonarr_data.rs b/src/models/servarr_data/sonarr/sonarr_data.rs index acd61a5..3cb44b6 100644 --- a/src/models/servarr_data/sonarr/sonarr_data.rs +++ b/src/models/servarr_data/sonarr/sonarr_data.rs @@ -1,14 +1,17 @@ +use bimap::BiMap; use chrono::{DateTime, Utc}; use strum::EnumIter; use crate::models::{ - sonarr_models::{BlocklistItem, Episode, Series}, + sonarr_models::{BlocklistItem, DownloadRecord, Episode, Series}, stateful_list::StatefulList, stateful_table::StatefulTable, stateful_tree::StatefulTree, HorizontallyScrollableText, Route, }; +use super::modals::EpisodeDetailsModal; + #[cfg(test)] #[path = "sonarr_data_tests.rs"] mod sonarr_data_tests; @@ -19,7 +22,11 @@ pub struct SonarrData { pub series: StatefulTable, pub blocklist: StatefulTable, pub logs: StatefulList, - pub episodes: StatefulTree, + pub episodes_tree: StatefulTree, + pub episodes_table: StatefulTable, + pub downloads: StatefulTable, + pub episode_details_modal: Option, + pub quality_profile_map: BiMap, } impl Default for SonarrData { @@ -30,7 +37,11 @@ impl Default for SonarrData { series: StatefulTable::default(), blocklist: StatefulTable::default(), logs: StatefulList::default(), - episodes: StatefulTree::default(), + episodes_tree: StatefulTree::default(), + episodes_table: StatefulTable::default(), + downloads: StatefulTable::default(), + episode_details_modal: None, + quality_profile_map: BiMap::new(), } } } @@ -39,6 +50,9 @@ impl Default for SonarrData { pub enum ActiveSonarrBlock { Blocklist, BlocklistSortPrompt, + EpisodesExplorer, + EpisodesTable, + EpisodesTableSortPrompt, #[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 86a1937..24d40ea 100644 --- a/src/models/servarr_data/sonarr/sonarr_data_tests.rs +++ b/src/models/servarr_data/sonarr/sonarr_data_tests.rs @@ -39,6 +39,11 @@ mod tests { assert!(sonarr_data.series.is_empty()); assert!(sonarr_data.blocklist.is_empty()); assert!(sonarr_data.logs.is_empty()); + assert!(sonarr_data.episodes_tree.is_empty()); + assert!(sonarr_data.episodes_table.is_empty()); + assert!(sonarr_data.downloads.is_empty()); + assert!(sonarr_data.episode_details_modal.is_none()); + assert!(sonarr_data.quality_profile_map.is_empty()); } } } diff --git a/src/models/sonarr_models.rs b/src/models/sonarr_models.rs index afdebaa..fd65f1e 100644 --- a/src/models/sonarr_models.rs +++ b/src/models/sonarr_models.rs @@ -37,6 +37,25 @@ pub struct BlocklistResponse { pub records: Vec, } +#[derive(Derivative, Serialize, Deserialize, Debug, Default, Clone, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct DownloadRecord { + pub title: String, + pub status: String, + #[serde(deserialize_with = "super::from_i64")] + pub id: i64, + #[serde(deserialize_with = "super::from_i64")] + pub episode_id: i64, + #[serde(deserialize_with = "super::from_i64")] + pub size: i64, + #[serde(deserialize_with = "super::from_i64")] + pub sizeleft: i64, + pub output_path: Option, + #[serde(default)] + pub indexer: String, + pub download_client: String, +} + #[derive(Default, Serialize, Deserialize, Hash, Debug, Clone, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct Episode { @@ -57,6 +76,7 @@ pub struct Episode { pub overview: Option, pub has_file: bool, pub monitored: bool, + pub episode_file: Option, } impl Display for Episode { @@ -65,7 +85,19 @@ impl Display for Episode { } } -#[derive(Serialize, Deserialize, Default, Debug, Clone, PartialEq, Eq, Ord, PartialOrd)] +#[derive(Default, Serialize, Deserialize, Hash, Debug, Clone, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct EpisodeFile { + pub relative_path: String, + pub path: String, + #[serde(deserialize_with = "super::from_i64")] + pub size: i64, + pub language: Language, + pub date_added: DateTime, + pub media_info: Option, +} + +#[derive(Serialize, Deserialize, Default, Debug, Hash, Clone, PartialEq, Eq, Ord, PartialOrd)] pub struct Language { pub name: String, } @@ -87,16 +119,48 @@ pub struct LogResponse { pub records: Vec, } -#[derive(Serialize, Deserialize, Default, Debug, Clone, PartialEq, Eq, Ord, PartialOrd)] +#[derive(Serialize, Deserialize, Derivative, Hash, Debug, Clone, PartialEq, Eq)] +#[derivative(Default)] +#[serde(rename_all = "camelCase")] +pub struct MediaInfo { + #[serde(deserialize_with = "super::from_i64")] + pub audio_bitrate: i64, + #[derivative(Default(value = "Number::from(0)"))] + pub audio_channels: Number, + pub audio_codec: Option, + pub audio_languages: Option, + #[serde(deserialize_with = "super::from_i64")] + pub audio_stream_count: i64, + #[serde(deserialize_with = "super::from_i64")] + pub video_bit_depth: i64, + #[serde(deserialize_with = "super::from_i64")] + pub video_bitrate: i64, + pub video_codec: String, + #[derivative(Default(value = "Number::from(0)"))] + pub video_fps: Number, + pub resolution: String, + pub run_time: String, + pub scan_type: String, + pub subtitles: Option, +} + +#[derive(Serialize, Deserialize, Default, Debug, Hash, Clone, PartialEq, Eq, Ord, PartialOrd)] pub struct Quality { pub name: String, } -#[derive(Serialize, Deserialize, Default, Debug, Clone, PartialEq, Eq, Ord, PartialOrd)] +#[derive(Serialize, Deserialize, Default, Debug, Hash, Clone, PartialEq, Eq, Ord, PartialOrd)] pub struct QualityWrapper { pub quality: Quality, } +#[derive(Default, Clone, Serialize, Deserialize, Debug, PartialEq, Eq)] +pub struct QualityProfile { + #[serde(deserialize_with = "super::from_i64")] + pub id: i64, + pub name: String, +} + #[derive(Derivative, Serialize, Deserialize, Debug, Clone, PartialEq)] #[derivative(Default)] pub struct Rating { @@ -257,7 +321,9 @@ impl SeriesStatus { #[allow(clippy::large_enum_variant)] pub enum SonarrSerdeable { Value(Value), + Episode(Episode), Episodes(Vec), + QualityProfiles(Vec), SeriesVec(Vec), SystemStatus(SystemStatus), BlocklistResponse(BlocklistResponse), @@ -279,7 +345,9 @@ impl From<()> for SonarrSerdeable { serde_enum_from!( SonarrSerdeable { Value(Value), + Episode(Episode), Episodes(Vec), + QualityProfiles(Vec), SeriesVec(Vec), SystemStatus(SystemStatus), BlocklistResponse(BlocklistResponse), diff --git a/src/models/sonarr_models_tests.rs b/src/models/sonarr_models_tests.rs index e4c659a..162ff7e 100644 --- a/src/models/sonarr_models_tests.rs +++ b/src/models/sonarr_models_tests.rs @@ -5,8 +5,8 @@ mod tests { use crate::models::{ sonarr_models::{ - BlocklistItem, BlocklistResponse, Episode, Log, LogResponse, Series, SeriesStatus, - SeriesType, SonarrSerdeable, SystemStatus, + BlocklistItem, BlocklistResponse, Episode, Log, LogResponse, QualityProfile, Series, + SeriesStatus, SeriesType, SonarrSerdeable, SystemStatus, }, Serdeable, }; @@ -77,6 +77,18 @@ mod tests { assert_eq!(sonarr_serdeable, SonarrSerdeable::Value(value)); } + #[test] + fn test_sonarr_serdeable_from_episode() { + let episode = Episode { + id: 1, + ..Episode::default() + }; + + let sonarr_serdeable: SonarrSerdeable = episode.clone().into(); + + assert_eq!(sonarr_serdeable, SonarrSerdeable::Episode(episode)); + } + #[test] fn test_sonarr_serdeable_from_episodes() { let episodes = vec![Episode { @@ -146,4 +158,19 @@ mod tests { assert_eq!(sonarr_serdeable, SonarrSerdeable::LogResponse(log_response)); } + + #[test] + fn test_sonarr_serdeable_from_quality_profiles() { + let quality_profiles = vec![QualityProfile { + name: "Test Profile".to_owned(), + id: 1, + }]; + + let sonarr_serdeable: SonarrSerdeable = quality_profiles.clone().into(); + + assert_eq!( + sonarr_serdeable, + SonarrSerdeable::QualityProfiles(quality_profiles) + ); + } } diff --git a/src/network/radarr_network.rs b/src/network/radarr_network.rs index 936178b..cff5517 100644 --- a/src/network/radarr_network.rs +++ b/src/network/radarr_network.rs @@ -211,9 +211,10 @@ impl<'a, 'b> Network<'a, 'b> { .map(RadarrSerdeable::from), RadarrEvent::GetMovies => self.get_movies().await.map(RadarrSerdeable::from), RadarrEvent::GetOverview => self.get_diskspace().await.map(RadarrSerdeable::from), - RadarrEvent::GetQualityProfiles => { - self.get_quality_profiles().await.map(RadarrSerdeable::from) - } + RadarrEvent::GetQualityProfiles => self + .get_radarr_quality_profiles() + .await + .map(RadarrSerdeable::from), RadarrEvent::GetQueuedEvents => self.get_queued_events().await.map(RadarrSerdeable::from), RadarrEvent::GetReleases(movie_id) => { self.get_releases(movie_id).await.map(RadarrSerdeable::from) @@ -1702,7 +1703,7 @@ impl<'a, 'b> Network<'a, 'b> { .await } - async fn get_quality_profiles(&mut self) -> Result> { + async fn get_radarr_quality_profiles(&mut self) -> Result> { info!("Fetching Radarr quality profiles"); let event = RadarrEvent::GetQualityProfiles; diff --git a/src/network/radarr_network_tests.rs b/src/network/radarr_network_tests.rs index f386a11..efee36e 100644 --- a/src/network/radarr_network_tests.rs +++ b/src/network/radarr_network_tests.rs @@ -2586,7 +2586,7 @@ mod test { } #[tokio::test] - async fn test_handle_get_quality_profiles_event() { + async fn test_handle_get_radarr_quality_profiles_event() { let quality_profile_json = json!([{ "id": 2222, "name": "HD - 1080p" diff --git a/src/network/sonarr_network.rs b/src/network/sonarr_network.rs index 252ab08..03bbf7c 100644 --- a/src/network/sonarr_network.rs +++ b/src/network/sonarr_network.rs @@ -1,19 +1,22 @@ use std::collections::BTreeMap; use anyhow::Result; +use indoc::formatdoc; use log::info; use managarr_tree_widget::TreeItem; use serde_json::{json, Value}; use crate::{ models::{ - servarr_data::sonarr::sonarr_data::ActiveSonarrBlock, + servarr_data::sonarr::{modals::EpisodeDetailsModal, sonarr_data::ActiveSonarrBlock}, sonarr_models::{ - BlocklistResponse, Episode, LogResponse, Series, SonarrSerdeable, SystemStatus, + BlocklistResponse, DownloadRecord, Episode, LogResponse, QualityProfile, Series, + SonarrSerdeable, SystemStatus, }, - HorizontallyScrollableText, Route, Scrollable, + HorizontallyScrollableText, Route, Scrollable, ScrollableText, }, network::RequestMethod, + utils::convert_to_gb, }; use super::{Network, NetworkEvent, NetworkResource}; @@ -26,8 +29,10 @@ pub enum SonarrEvent { ClearBlocklist, DeleteBlocklistItem(Option), GetBlocklist, + GetEpisodeDetails(Option), GetEpisodes(Option), GetLogs(Option), + GetQualityProfiles, GetStatus, HealthCheck, ListSeries, @@ -39,8 +44,9 @@ impl NetworkResource for SonarrEvent { SonarrEvent::ClearBlocklist => "/blocklist/bulk", SonarrEvent::DeleteBlocklistItem(_) => "/blocklist", SonarrEvent::GetBlocklist => "/blocklist?page=1&pageSize=10000", - SonarrEvent::GetEpisodes(_) => "/episode", + SonarrEvent::GetEpisodes(_) | SonarrEvent::GetEpisodeDetails(_) => "/episode", SonarrEvent::GetLogs(_) => "/log", + SonarrEvent::GetQualityProfiles => "/qualityprofile", SonarrEvent::GetStatus => "/system/status", SonarrEvent::HealthCheck => "/health", SonarrEvent::ListSeries => "/series", @@ -73,6 +79,14 @@ impl<'a, 'b> Network<'a, 'b> { .get_episodes(series_id) .await .map(SonarrSerdeable::from), + SonarrEvent::GetEpisodeDetails(episode_id) => self + .get_episode_details(episode_id) + .await + .map(SonarrSerdeable::from), + SonarrEvent::GetQualityProfiles => self + .get_sonarr_quality_profiles() + .await + .map(SonarrSerdeable::from), SonarrEvent::GetLogs(events) => self .get_sonarr_logs(events) .await @@ -204,6 +218,22 @@ impl<'a, 'b> Network<'a, 'b> { self .handle_request::<(), Vec>(request_props, |mut episode_vec, mut app| { episode_vec.sort_by(|a, b| a.id.cmp(&b.id)); + if !matches!( + app.get_current_route(), + Route::Sonarr(ActiveSonarrBlock::EpisodesTableSortPrompt, _) + ) { + app + .data + .sonarr_data + .episodes_table + .set_items(episode_vec.clone()); + app + .data + .sonarr_data + .episodes_table + .apply_sorting_toggle(false); + } + let mut seasons = BTreeMap::new(); for episode in episode_vec { @@ -226,7 +256,114 @@ impl<'a, 'b> Network<'a, 'b> { }) .collect(); - app.data.sonarr_data.episodes.set_items(tree); + app.data.sonarr_data.episodes_tree.set_items(tree); + }) + .await + } + + async fn get_episode_details(&mut self, episode_id: Option) -> Result { + info!("Fetching Sonarr episode details"); + let event = SonarrEvent::GetEpisodeDetails(None); + let id = self.extract_episode_id(episode_id).await; + + info!("Fetching episode details for episode with ID: {id}"); + + let request_props = self + .request_props_from( + event, + RequestMethod::Get, + None::<()>, + Some(format!("/{id}")), + None, + ) + .await; + + self + .handle_request::<(), Episode>(request_props, |episode_response, mut app| { + let Episode { + id, + title, + air_date_utc, + overview, + has_file, + season_number, + episode_number, + episode_file, + .. + } = episode_response; + let status = get_episode_status(has_file, &app.data.sonarr_data.downloads.items, id); + let air_date = if let Some(air_date) = air_date_utc { + format!("{air_date}") + } else { + String::new() + }; + let mut episode_details_modal = EpisodeDetailsModal { + episode_details: ScrollableText::with_string(formatdoc!( + " + Title: {} + Season: {season_number} + Episode Number: {episode_number} + Air Date: {air_date} + Status: {status} + Description: {}", + title.unwrap_or_default(), + overview.unwrap_or_default(), + )), + ..EpisodeDetailsModal::default() + }; + if let Some(file) = episode_file { + let size = convert_to_gb(file.size); + episode_details_modal.file_details = formatdoc!( + " + Relative Path: {} + Absolute Path: {} + Size: {size:.2} GB + Language: {} + Date Added: {}", + file.relative_path, + file.path, + file.language.name, + file.date_added, + ); + + if let Some(media_info) = file.media_info { + episode_details_modal.audio_details = formatdoc!( + " + Bitrate: {} + Channels: {:.1} + Codec: {} + Languages: {} + Stream Count: {}", + media_info.audio_bitrate, + media_info.audio_channels.as_f64().unwrap(), + media_info.audio_codec.unwrap_or_default(), + media_info.audio_languages.unwrap_or_default(), + media_info.audio_stream_count + ); + + episode_details_modal.video_details = formatdoc!( + " + Bit Depth: {} + Bitrate: {} + Codec: {} + FPS: {} + Resolution: {} + Scan Type: {} + Runtime: {} + Subtitles: {}", + media_info.video_bit_depth, + media_info.video_bitrate, + media_info.video_codec, + media_info.video_fps.as_f64().unwrap(), + media_info.resolution, + media_info.scan_type, + media_info.run_time, + media_info.subtitles.unwrap_or_default() + ); + } + }; + + app.data.sonarr_data.episode_details_modal = Some(episode_details_modal); }) .await } @@ -278,6 +415,24 @@ impl<'a, 'b> Network<'a, 'b> { .await } + async fn get_sonarr_quality_profiles(&mut self) -> Result> { + info!("Fetching Sonarr quality profiles"); + let event = SonarrEvent::GetQualityProfiles; + + let request_props = self + .request_props_from(event, RequestMethod::Get, None::<()>, None, None) + .await; + + self + .handle_request::<(), Vec>(request_props, |quality_profiles, mut app| { + app.data.sonarr_data.quality_profile_map = quality_profiles + .into_iter() + .map(|profile| (profile.id, profile.name)) + .collect(); + }) + .await + } + async fn list_series(&mut self) -> Result> { info!("Fetching Sonarr library"); let event = SonarrEvent::ListSeries; @@ -332,4 +487,49 @@ impl<'a, 'b> Network<'a, 'b> { }; (series_id, format!("seriesId={series_id}")) } + + async fn extract_episode_id(&mut self, episode_id: Option) -> i64 { + let app = self.app.lock().await; + + let episode_id = if let Some(id) = episode_id { + id + } else if matches!( + app.get_current_route(), + Route::Sonarr(ActiveSonarrBlock::EpisodesTable, _) + ) { + app.data.sonarr_data.episodes_table.current_selection().id + } else { + app + .data + .sonarr_data + .episodes_tree + .current_selection() + .as_ref() + .unwrap() + .id + }; + + episode_id + } +} + +fn get_episode_status(has_file: bool, downloads_vec: &[DownloadRecord], episode_id: i64) -> String { + if !has_file { + if let Some(download) = downloads_vec + .iter() + .find(|&download| download.episode_id == episode_id) + { + if download.status == "downloading" { + return "Downloading".to_owned(); + } + + if download.status == "completed" { + return "Awaiting Import".to_owned(); + } + } + + return "Missing".to_owned(); + } + + "Downloaded".to_owned() } diff --git a/src/network/sonarr_network_tests.rs b/src/network/sonarr_network_tests.rs index 6b8c0d3..bb0b182 100644 --- a/src/network/sonarr_network_tests.rs +++ b/src/network/sonarr_network_tests.rs @@ -1,10 +1,18 @@ #[cfg(test)] mod test { + use std::fmt::Display; + use std::hash::Hash; use std::sync::Arc; + use bimap::BiMap; use chrono::{DateTime, Utc}; - use managarr_tree_widget::TreeItem; + use indoc::formatdoc; + use managarr_tree_widget::{Tree, TreeItem, TreeState}; use pretty_assertions::{assert_eq, assert_str_eq}; + use ratatui::buffer::Buffer; + use ratatui::layout::Rect; + use ratatui::text::ToText; + use ratatui::widgets::StatefulWidget; use reqwest::Client; use rstest::rstest; use serde_json::json; @@ -14,13 +22,17 @@ mod test { use crate::app::App; use crate::models::servarr_data::sonarr::sonarr_data::ActiveSonarrBlock; - use crate::models::sonarr_models::{BlocklistItem, Episode, Language, LogResponse}; + use crate::models::sonarr_models::{ + BlocklistItem, DownloadRecord, Episode, EpisodeFile, Language, LogResponse, MediaInfo, + QualityProfile, + }; use crate::models::sonarr_models::{BlocklistResponse, Quality}; use crate::models::sonarr_models::{QualityWrapper, SystemStatus}; use crate::models::stateful_table::StatefulTable; use crate::models::HorizontallyScrollableText; use crate::models::{sonarr_models::SonarrSerdeable, stateful_table::SortOption}; + use crate::network::sonarr_network::get_episode_status; use crate::{ models::sonarr_models::{ Rating, Season, SeasonStatistics, Series, SeriesStatistics, SeriesStatus, SeriesType, @@ -75,6 +87,50 @@ mod test { "id": 1 } "#; + const EPISODE_JSON: &str = r#"{ + "seriesId": 1, + "tvdbId": 1234, + "episodeFileId": 1, + "seasonNumber": 1, + "episodeNumber": 1, + "title": "Something cool", + "airDateUtc": "2024-02-10T07:28:45Z", + "overview": "Okay so this one time at band camp...", + "episodeFile": { + "relativePath": "/season 1/episode 1.mkv", + "path": "/nfs/tv/series/season 1/episode 1.mkv", + "size": 3543348019, + "dateAdded": "2024-02-10T07:28:45Z", + "language": { "name": "English" }, + "quality": { "quality": { "name": "Bluray-1080p" } }, + "mediaInfo": { + "audioBitrate": 0, + "audioChannels": 7.1, + "audioCodec": "AAC", + "audioLanguages": "eng", + "audioStreamCount": 1, + "videoBitDepth": 10, + "videoBitrate": 0, + "videoCodec": "x265", + "videoFps": 23.976, + "resolution": "1920x1080", + "runTime": "23:51", + "scanType": "Progressive", + "subtitles": "English" + } + }, + "hasFile": true, + "monitored": true, + "id": 1 + }"#; + + #[rstest] + fn test_resource_episode( + #[values(SonarrEvent::GetEpisodes(None), SonarrEvent::GetEpisodeDetails(None))] + event: SonarrEvent, + ) { + assert_str_eq!(event.resource(), "/episode"); + } #[rstest] fn test_resource_series(#[values(SonarrEvent::ListSeries)] event: SonarrEvent) { @@ -86,8 +142,8 @@ mod test { #[case(SonarrEvent::DeleteBlocklistItem(None), "/blocklist")] #[case(SonarrEvent::HealthCheck, "/health")] #[case(SonarrEvent::GetBlocklist, "/blocklist?page=1&pageSize=10000")] - #[case(SonarrEvent::GetEpisodes(None), "/episode")] #[case(SonarrEvent::GetLogs(Some(500)), "/log")] + #[case(SonarrEvent::GetQualityProfiles, "/qualityprofile")] #[case(SonarrEvent::GetStatus, "/system/status")] fn test_resource(#[case] event: SonarrEvent, #[case] expected_uri: String) { assert_str_eq!(event.resource(), expected_uri); @@ -285,8 +341,124 @@ mod test { async_server.assert_async().await; } + #[rstest] #[tokio::test] - async fn test_handle_get_episodes_event() { + async fn test_handle_get_episodes_event(#[values(true, false)] use_custom_sorting: bool) { + let marker_episode_1 = Episode { + title: Some("Season 1".to_owned()), + ..Episode::default() + }; + let marker_episode_2 = Episode { + title: Some("Season 2".to_owned()), + ..Episode::default() + }; + let episode_1 = Episode { + title: Some("z test".to_owned()), + episode_file: None, + ..episode() + }; + let episode_2 = Episode { + id: 2, + title: Some("A test".to_owned()), + episode_file_id: 2, + season_number: 2, + episode_number: 2, + episode_file: None, + ..episode() + }; + let expected_episodes = vec![episode_1.clone(), episode_2.clone()]; + let mut expected_sorted_episodes = vec![episode_1.clone(), episode_2.clone()]; + let expected_tree = vec![ + TreeItem::new( + marker_episode_1, + vec![TreeItem::new_leaf(episode_1.clone())], + ) + .unwrap(), + TreeItem::new( + marker_episode_2, + vec![TreeItem::new_leaf(episode_2.clone())], + ) + .unwrap(), + ]; + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(json!([episode_1, episode_2])), + None, + SonarrEvent::GetEpisodes(None), + None, + Some("seriesId=1"), + ) + .await; + app_arc + .lock() + .await + .data + .sonarr_data + .episodes_table + .sort_asc = true; + if use_custom_sorting { + let cmp_fn = |a: &Episode, b: &Episode| { + a.title + .as_ref() + .unwrap() + .to_lowercase() + .cmp(&b.title.as_ref().unwrap().to_lowercase()) + }; + expected_sorted_episodes.sort_by(cmp_fn); + let title_sort_option = SortOption { + name: "Title", + cmp_fn: Some(cmp_fn), + }; + app_arc + .lock() + .await + .data + .sonarr_data + .episodes_table + .sorting(vec![title_sort_option]); + } + app_arc + .lock() + .await + .data + .sonarr_data + .series + .set_items(vec![Series { + id: 1, + ..Series::default() + }]); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + if let SonarrSerdeable::Episodes(episodes) = network + .handle_sonarr_event(SonarrEvent::GetEpisodes(None)) + .await + .unwrap() + { + async_server.assert_async().await; + assert_eq!( + app_arc.lock().await.data.sonarr_data.episodes_table.items, + expected_sorted_episodes + ); + assert!( + app_arc + .lock() + .await + .data + .sonarr_data + .episodes_table + .sort_asc + ); + assert_eq!( + app_arc.lock().await.data.sonarr_data.episodes_tree.items, + expected_tree + ); + assert_eq!(episodes, expected_episodes); + } + } + + #[tokio::test] + async fn test_handle_get_episodes_event_no_op_while_user_is_selecting_sort_options_on_table() { let episodes_json = json!([ { "id": 2, @@ -323,15 +495,19 @@ mod test { title: Some("Season 2".to_owned()), ..Episode::default() }; - let episode_1 = episode(); + let episode_1 = Episode { + episode_file: None, + ..episode() + }; let episode_2 = Episode { id: 2, episode_file_id: 2, season_number: 2, episode_number: 2, + episode_file: None, ..episode() }; - let expected_episodes = vec![episode_2.clone(), episode_1.clone()]; + let mut expected_episodes = vec![episode_2.clone(), episode_1.clone()]; let expected_tree = vec![ TreeItem::new( marker_episode_1, @@ -354,6 +530,36 @@ mod test { Some("seriesId=1"), ) .await; + app_arc + .lock() + .await + .push_navigation_stack(ActiveSonarrBlock::EpisodesTableSortPrompt.into()); + app_arc + .lock() + .await + .data + .sonarr_data + .episodes_table + .sort_asc = true; + let cmp_fn = |a: &Episode, b: &Episode| { + a.title + .as_ref() + .unwrap() + .to_lowercase() + .cmp(&b.title.as_ref().unwrap().to_lowercase()) + }; + expected_episodes.sort_by(cmp_fn); + let title_sort_option = SortOption { + name: "Title", + cmp_fn: Some(cmp_fn), + }; + app_arc + .lock() + .await + .data + .sonarr_data + .episodes_table + .sorting(vec![title_sort_option]); app_arc .lock() .await @@ -372,8 +578,24 @@ mod test { .unwrap() { async_server.assert_async().await; + assert!(app_arc + .lock() + .await + .data + .sonarr_data + .episodes_table + .is_empty()); + assert!( + app_arc + .lock() + .await + .data + .sonarr_data + .episodes_table + .sort_asc + ); assert_eq!( - app_arc.lock().await.data.sonarr_data.episodes.items, + app_arc.lock().await.data.sonarr_data.episodes_tree.items, expected_tree ); assert_eq!(episodes, expected_episodes); @@ -420,6 +642,7 @@ mod test { }; let episode_1 = Episode { series_id: 2, + episode_file: None, ..episode() }; let episode_2 = Episode { @@ -428,6 +651,7 @@ mod test { season_number: 2, episode_number: 2, series_id: 2, + episode_file: None, ..episode() }; let expected_episodes = vec![episode_2.clone(), episode_1.clone()]; @@ -462,13 +686,128 @@ mod test { { async_server.assert_async().await; assert_eq!( - app_arc.lock().await.data.sonarr_data.episodes.items, + app_arc.lock().await.data.sonarr_data.episodes_tree.items, expected_tree ); assert_eq!(episodes, expected_episodes); } } + #[tokio::test] + async fn test_handle_get_episode_details_event() { + let response: Episode = serde_json::from_str(EPISODE_JSON).unwrap(); + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(serde_json::from_str(EPISODE_JSON).unwrap()), + None, + SonarrEvent::GetEpisodeDetails(None), + Some("/1"), + None, + ) + .await; + app_arc + .lock() + .await + .data + .sonarr_data + .episodes_table + .set_items(vec![episode()]); + app_arc + .lock() + .await + .push_navigation_stack(ActiveSonarrBlock::EpisodesTable.into()); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + if let SonarrSerdeable::Episode(episode) = network + .handle_sonarr_event(SonarrEvent::GetEpisodeDetails(None)) + .await + .unwrap() + { + async_server.assert_async().await; + assert!(app_arc + .lock() + .await + .data + .sonarr_data + .episode_details_modal + .is_some()); + assert_eq!(episode, response); + + let app = app_arc.lock().await; + let episode_details_modal = app.data.sonarr_data.episode_details_modal.as_ref().unwrap(); + assert_str_eq!( + episode_details_modal.episode_details.get_text(), + formatdoc!( + "Title: Something cool + Season: 1 + Episode Number: 1 + Air Date: 2024-02-10 07:28:45 UTC + Status: Downloaded + Description: Okay so this one time at band camp..." + ) + ); + assert_str_eq!( + episode_details_modal.file_details, + formatdoc!( + "Relative Path: /season 1/episode 1.mkv + Absolute Path: /nfs/tv/series/season 1/episode 1.mkv + Size: 3.30 GB + Language: English + Date Added: 2024-02-10 07:28:45 UTC" + ) + ); + assert_str_eq!( + episode_details_modal.audio_details, + formatdoc!( + "Bitrate: 0 + Channels: 7.1 + Codec: AAC + Languages: eng + Stream Count: 1" + ) + ); + assert_str_eq!( + episode_details_modal.video_details, + formatdoc!( + "Bit Depth: 10 + Bitrate: 0 + Codec: x265 + FPS: 23.976 + Resolution: 1920x1080 + Scan Type: Progressive + Runtime: 23:51 + Subtitles: English" + ) + ); + } + } + + #[tokio::test] + async fn test_handle_get_episode_details_event_uses_provided_id() { + let response: Episode = serde_json::from_str(EPISODE_JSON).unwrap(); + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(serde_json::from_str(EPISODE_JSON).unwrap()), + None, + SonarrEvent::GetEpisodeDetails(None), + Some("/1"), + None, + ) + .await; + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + if let SonarrSerdeable::Episode(episode) = network + .handle_sonarr_event(SonarrEvent::GetEpisodeDetails(Some(1))) + .await + .unwrap() + { + async_server.assert_async().await; + assert_eq!(episode, response); + } + } + #[tokio::test] async fn test_handle_get_sonarr_logs_event() { let expected_logs = vec![ @@ -605,6 +944,40 @@ mod test { } } + #[tokio::test] + async fn test_handle_get_sonarr_quality_profiles_event() { + let quality_profile_json = json!([{ + "id": 2222, + "name": "HD - 1080p" + }]); + let response: Vec = + serde_json::from_value(quality_profile_json.clone()).unwrap(); + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(quality_profile_json), + None, + SonarrEvent::GetQualityProfiles, + None, + None, + ) + .await; + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + if let SonarrSerdeable::QualityProfiles(quality_profiles) = network + .handle_sonarr_event(SonarrEvent::GetQualityProfiles) + .await + .unwrap() + { + async_server.assert_async().await; + assert_eq!( + app_arc.lock().await.data.sonarr_data.quality_profile_map, + BiMap::from_iter([(2222i64, "HD - 1080p".to_owned())]) + ); + assert_eq!(quality_profiles, response); + } + } + #[rstest] #[tokio::test] async fn test_handle_get_series_event(#[values(true, false)] use_custom_sorting: bool) { @@ -841,6 +1214,180 @@ mod test { assert_str_eq!(series_id_param, "seriesId=1"); } + #[tokio::test] + async fn test_extract_episode_id() { + let app_arc = Arc::new(Mutex::new(App::default())); + app_arc + .lock() + .await + .data + .sonarr_data + .episodes_table + .set_items(vec![Episode { + id: 1, + ..Episode::default() + }]); + app_arc + .lock() + .await + .push_navigation_stack(ActiveSonarrBlock::EpisodesTable.into()); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + let id = network.extract_episode_id(None).await; + + assert_eq!(id, 1); + } + + #[tokio::test] + async fn test_extract_episode_id_uses_provided_id() { + let app_arc = Arc::new(Mutex::new(App::default())); + app_arc + .lock() + .await + .data + .sonarr_data + .episodes_table + .set_items(vec![Episode { + id: 1, + ..Episode::default() + }]); + app_arc + .lock() + .await + .push_navigation_stack(ActiveSonarrBlock::EpisodesTable.into()); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + let id = network.extract_episode_id(Some(2)).await; + + assert_eq!(id, 2); + } + + #[tokio::test] + async fn test_extract_episode_id_filtered_series() { + let app_arc = Arc::new(Mutex::new(App::default())); + let mut filtered_episodes = StatefulTable::default(); + filtered_episodes.set_filtered_items(vec![Episode { + id: 1, + ..Episode::default() + }]); + app_arc.lock().await.data.sonarr_data.episodes_table = filtered_episodes; + app_arc + .lock() + .await + .push_navigation_stack(ActiveSonarrBlock::EpisodesTable.into()); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + let id = network.extract_episode_id(None).await; + + assert_eq!(id, 1); + } + + #[tokio::test] + async fn test_extract_episode_id_from_tree() { + let app_arc = Arc::new(Mutex::new(App::default())); + { + let mut app = app_arc.lock().await; + let items = vec![TreeItem::new_leaf(Episode { + id: 1, + ..Episode::default() + })]; + app.data.sonarr_data.episodes_tree.set_items(items.clone()); + render( + &mut app.data.sonarr_data.episodes_tree.state, + &items.clone(), + ); + app.data.sonarr_data.episodes_tree.state.key_down(); + render( + &mut app.data.sonarr_data.episodes_tree.state, + &items.clone(), + ); + } + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + let id = network.extract_episode_id(None).await; + + assert_eq!(id, 1); + } + + #[tokio::test] + async fn test_extract_episode_id_uses_provided_id_over_tree() { + let app_arc = Arc::new(Mutex::new(App::default())); + { + let mut app = app_arc.lock().await; + let items = vec![TreeItem::new_leaf(Episode { + id: 1, + ..Episode::default() + })]; + app.data.sonarr_data.episodes_tree.set_items(items.clone()); + render( + &mut app.data.sonarr_data.episodes_tree.state, + &items.clone(), + ); + app.data.sonarr_data.episodes_tree.state.key_down(); + render( + &mut app.data.sonarr_data.episodes_tree.state, + &items.clone(), + ); + } + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + let id = network.extract_episode_id(Some(2)).await; + + assert_eq!(id, 2); + } + + #[test] + fn test_get_episode_status_downloaded() { + assert_str_eq!(get_episode_status(true, &[], 0), "Downloaded"); + } + + #[test] + fn test_get_episode_status_missing() { + let download_record = DownloadRecord { + episode_id: 1, + ..DownloadRecord::default() + }; + + assert_str_eq!( + get_episode_status(false, &[download_record.clone()], 0), + "Missing" + ); + + assert_str_eq!(get_episode_status(false, &[download_record], 1), "Missing"); + } + + #[test] + fn test_get_episode_status_downloading() { + assert_str_eq!( + get_episode_status( + false, + &[DownloadRecord { + episode_id: 1, + status: "downloading".to_owned(), + ..DownloadRecord::default() + }], + 1 + ), + "Downloading" + ); + } + + #[test] + fn test_get_episode_status_awaiting_import() { + assert_str_eq!( + get_episode_status( + false, + &[DownloadRecord { + episode_id: 1, + status: "completed".to_owned(), + ..DownloadRecord::default() + }], + 1 + ), + "Awaiting Import" + ); + } + fn blocklist_item() -> BlocklistItem { BlocklistItem { id: 1, @@ -871,6 +1418,18 @@ mod test { overview: Some("Okay so this one time at band camp...".to_owned()), has_file: true, monitored: true, + episode_file: Some(episode_file()), + } + } + + fn episode_file() -> EpisodeFile { + EpisodeFile { + relative_path: "/season 1/episode 1.mkv".to_owned(), + path: "/nfs/tv/series/season 1/episode 1.mkv".to_owned(), + size: 3543348019, + language: language(), + date_added: DateTime::from(DateTime::parse_from_rfc3339("2024-02-10T07:28:45Z").unwrap()), + media_info: Some(media_info()), } } @@ -880,6 +1439,23 @@ mod test { } } + fn media_info() -> MediaInfo { + MediaInfo { + audio_bitrate: 0, + audio_channels: Number::from_f64(7.1).unwrap(), + audio_codec: Some("AAC".to_owned()), + audio_languages: Some("eng".to_owned()), + audio_stream_count: 1, + video_bit_depth: 10, + video_bitrate: 0, + video_codec: "x265".to_owned(), + video_fps: Number::from_f64(23.976).unwrap(), + resolution: "1920x1080".to_owned(), + run_time: "23:51".to_owned(), + scan_type: "Progressive".to_owned(), + subtitles: Some("English".to_owned()), + } + } fn quality() -> Quality { Quality { name: "Bluray-1080p".to_owned(), @@ -955,4 +1531,14 @@ mod test { percent_of_episodes: 100.0, } } + + fn render(state: &mut TreeState, items: &[TreeItem]) + where + T: ToText + Clone + Default + Display + Hash + PartialEq + Eq, + { + let tree = Tree::new(items).unwrap(); + let area = Rect::new(0, 0, 10, 4); + let mut buffer = Buffer::empty(area); + StatefulWidget::render(tree, area, &mut buffer, state); + } } From 003f3193858e510d116a63c7c198432640b5c3dc Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Fri, 15 Nov 2024 18:41:13 -0700 Subject: [PATCH 010/119] feat(cli): Added sonarr support for listing downloads, listing quality profiles, and fetching detailed information about an episode --- src/cli/sonarr/get_command_handler.rs | 12 ++++ src/cli/sonarr/get_command_handler_tests.rs | 56 +++++++++++++++- src/cli/sonarr/list_command_handler.rs | 10 +++ src/cli/sonarr/list_command_handler_tests.rs | 4 +- src/models/sonarr_models.rs | 8 +++ src/models/sonarr_models_tests.rs | 20 +++++- src/network/radarr_network.rs | 4 +- src/network/radarr_network_tests.rs | 2 +- src/network/sonarr_network.rs | 26 +++++++- src/network/sonarr_network_tests.rs | 70 +++++++++++++++++++- 10 files changed, 201 insertions(+), 11 deletions(-) diff --git a/src/cli/sonarr/get_command_handler.rs b/src/cli/sonarr/get_command_handler.rs index 1e361b9..7e42ca9 100644 --- a/src/cli/sonarr/get_command_handler.rs +++ b/src/cli/sonarr/get_command_handler.rs @@ -19,6 +19,15 @@ mod get_command_handler_tests; #[derive(Debug, Clone, PartialEq, Eq, Subcommand)] pub enum SonarrGetCommand { + #[command(about = "Get detailed information for the episode with the given ID")] + EpisodeDetails { + #[arg( + long, + help = "The Sonarr ID of the episode whose details you wish to fetch", + required = true + )] + episode_id: i64, + }, #[command(about = "Get the system status")] SystemStatus, } @@ -50,6 +59,9 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, SonarrGetCommand> for SonarrGetCommandHan async fn handle(self) -> Result<()> { match self.command { + SonarrGetCommand::EpisodeDetails { episode_id } => { + execute_network_event!(self, SonarrEvent::GetEpisodeDetails(Some(episode_id))); + } SonarrGetCommand::SystemStatus => { execute_network_event!(self, SonarrEvent::GetStatus); } diff --git a/src/cli/sonarr/get_command_handler_tests.rs b/src/cli/sonarr/get_command_handler_tests.rs index 3bddd8d..3f6a513 100644 --- a/src/cli/sonarr/get_command_handler_tests.rs +++ b/src/cli/sonarr/get_command_handler_tests.rs @@ -17,12 +17,40 @@ mod tests { } mod cli { + use clap::error::ErrorKind; + use super::*; #[test] fn test_system_status_has_no_arg_requirements() { let result = - Cli::command().try_get_matches_from(["managarr", "radarr", "get", "system-status"]); + Cli::command().try_get_matches_from(["managarr", "sonarr", "get", "system-status"]); + + assert!(result.is_ok()); + } + + #[test] + fn test_episode_details_requires_episode_id() { + let result = + Cli::command().try_get_matches_from(["managarr", "sonarr", "get", "episode-details"]); + + assert!(result.is_err()); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_episode_details_requirements_satisfied() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "sonarr", + "get", + "episode-details", + "--episode-id", + "1", + ]); assert!(result.is_ok()); } @@ -45,6 +73,32 @@ mod tests { network::{sonarr_network::SonarrEvent, MockNetworkTrait, NetworkEvent}, }; + #[tokio::test] + async fn test_handle_get_episode_details_command() { + let expected_episode_id = 1; + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::( + SonarrEvent::GetEpisodeDetails(Some(expected_episode_id)).into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Sonarr(SonarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::default())); + let get_episode_details_command = SonarrGetCommand::EpisodeDetails { episode_id: 1 }; + + let result = + SonarrGetCommandHandler::with(&app_arc, get_episode_details_command, &mut mock_network) + .handle() + .await; + + assert!(result.is_ok()); + } + #[tokio::test] async fn test_handle_get_system_status_command() { let mut mock_network = MockNetworkTrait::new(); diff --git a/src/cli/sonarr/list_command_handler.rs b/src/cli/sonarr/list_command_handler.rs index 227ba9a..224bd09 100644 --- a/src/cli/sonarr/list_command_handler.rs +++ b/src/cli/sonarr/list_command_handler.rs @@ -21,6 +21,8 @@ mod list_command_handler_tests; pub enum SonarrListCommand { #[command(about = "List all items in the Sonarr blocklist")] Blocklist, + #[command(about = "List all active downloads in Sonarr")] + Downloads, #[command(about = "List the episodes for the series with the given ID")] Episodes { #[arg( @@ -40,6 +42,8 @@ pub enum SonarrListCommand { )] output_in_log_format: bool, }, + #[command(about = "List all Sonarr quality profiles")] + QualityProfiles, #[command(about = "List all series in your Sonarr library")] Series, } @@ -74,6 +78,9 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, SonarrListCommand> for SonarrListCommandH SonarrListCommand::Blocklist => { execute_network_event!(self, SonarrEvent::GetBlocklist); } + SonarrListCommand::Downloads => { + execute_network_event!(self, SonarrEvent::GetDownloads); + } SonarrListCommand::Episodes { series_id } => { execute_network_event!(self, SonarrEvent::GetEpisodes(Some(series_id))); } @@ -96,6 +103,9 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, SonarrListCommand> for SonarrListCommandH println!("{}", json); } } + SonarrListCommand::QualityProfiles => { + execute_network_event!(self, SonarrEvent::GetQualityProfiles); + } 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 b004b0a..6e01e4b 100644 --- a/src/cli/sonarr/list_command_handler_tests.rs +++ b/src/cli/sonarr/list_command_handler_tests.rs @@ -24,7 +24,7 @@ mod tests { #[rstest] fn test_list_commands_have_no_arg_requirements( - #[values("blocklist", "series")] subcommand: &str, + #[values("blocklist", "series", "downloads", "quality-profiles")] subcommand: &str, ) { let result = Cli::command().try_get_matches_from(["managarr", "sonarr", "list", subcommand]); @@ -102,6 +102,8 @@ mod tests { #[rstest] #[case(SonarrListCommand::Blocklist, SonarrEvent::GetBlocklist)] + #[case(SonarrListCommand::Downloads, SonarrEvent::GetDownloads)] + #[case(SonarrListCommand::QualityProfiles, SonarrEvent::GetQualityProfiles)] #[case(SonarrListCommand::Series, SonarrEvent::ListSeries)] #[tokio::test] async fn test_handle_list_command( diff --git a/src/models/sonarr_models.rs b/src/models/sonarr_models.rs index fd65f1e..6e1d3b1 100644 --- a/src/models/sonarr_models.rs +++ b/src/models/sonarr_models.rs @@ -56,6 +56,12 @@ pub struct DownloadRecord { pub download_client: String, } +#[derive(Default, Clone, Serialize, Deserialize, Debug, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct DownloadsResponse { + pub records: Vec, +} + #[derive(Default, Serialize, Deserialize, Hash, Debug, Clone, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct Episode { @@ -321,6 +327,7 @@ impl SeriesStatus { #[allow(clippy::large_enum_variant)] pub enum SonarrSerdeable { Value(Value), + DownloadsResponse(DownloadsResponse), Episode(Episode), Episodes(Vec), QualityProfiles(Vec), @@ -345,6 +352,7 @@ impl From<()> for SonarrSerdeable { serde_enum_from!( SonarrSerdeable { Value(Value), + DownloadsResponse(DownloadsResponse), Episode(Episode), Episodes(Vec), QualityProfiles(Vec), diff --git a/src/models/sonarr_models_tests.rs b/src/models/sonarr_models_tests.rs index 162ff7e..424e7f0 100644 --- a/src/models/sonarr_models_tests.rs +++ b/src/models/sonarr_models_tests.rs @@ -5,8 +5,8 @@ mod tests { use crate::models::{ sonarr_models::{ - BlocklistItem, BlocklistResponse, Episode, Log, LogResponse, QualityProfile, Series, - SeriesStatus, SeriesType, SonarrSerdeable, SystemStatus, + BlocklistItem, BlocklistResponse, DownloadRecord, DownloadsResponse, Episode, Log, + LogResponse, QualityProfile, Series, SeriesStatus, SeriesType, SonarrSerdeable, SystemStatus, }, Serdeable, }; @@ -145,6 +145,22 @@ mod tests { ); } + #[test] + fn test_sonarr_serdeable_from_downloads_response() { + let downloads_response = DownloadsResponse { + records: vec![DownloadRecord { + id: 1, + ..DownloadRecord::default() + }], + }; + let sonarr_serdeable: SonarrSerdeable = downloads_response.clone().into(); + + assert_eq!( + sonarr_serdeable, + SonarrSerdeable::DownloadsResponse(downloads_response) + ); + } + #[test] fn test_sonarr_serdeable_from_log_response() { let log_response = LogResponse { diff --git a/src/network/radarr_network.rs b/src/network/radarr_network.rs index cff5517..d673623 100644 --- a/src/network/radarr_network.rs +++ b/src/network/radarr_network.rs @@ -191,7 +191,7 @@ impl<'a, 'b> Network<'a, 'b> { .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::GetDownloads => self.get_radarr_downloads().await.map(RadarrSerdeable::from), RadarrEvent::GetHostConfig => self.get_host_config().await.map(RadarrSerdeable::from), RadarrEvent::GetIndexers => self.get_indexers().await.map(RadarrSerdeable::from), RadarrEvent::GetLogs(events) => self @@ -1363,7 +1363,7 @@ impl<'a, 'b> Network<'a, 'b> { .await } - async fn get_downloads(&mut self) -> Result { + async fn get_radarr_downloads(&mut self) -> Result { info!("Fetching Radarr downloads"); let event = RadarrEvent::GetDownloads; diff --git a/src/network/radarr_network_tests.rs b/src/network/radarr_network_tests.rs index efee36e..2957295 100644 --- a/src/network/radarr_network_tests.rs +++ b/src/network/radarr_network_tests.rs @@ -2182,7 +2182,7 @@ mod test { } #[tokio::test] - async fn test_handle_get_downloads_event() { + async fn test_handle_get_radarr_downloads_event() { let downloads_response_json = json!({ "records": [{ "title": "Test Download Title", diff --git a/src/network/sonarr_network.rs b/src/network/sonarr_network.rs index 03bbf7c..29423df 100644 --- a/src/network/sonarr_network.rs +++ b/src/network/sonarr_network.rs @@ -10,8 +10,8 @@ use crate::{ models::{ servarr_data::sonarr::{modals::EpisodeDetailsModal, sonarr_data::ActiveSonarrBlock}, sonarr_models::{ - BlocklistResponse, DownloadRecord, Episode, LogResponse, QualityProfile, Series, - SonarrSerdeable, SystemStatus, + BlocklistResponse, DownloadRecord, DownloadsResponse, Episode, LogResponse, QualityProfile, + Series, SonarrSerdeable, SystemStatus, }, HorizontallyScrollableText, Route, Scrollable, ScrollableText, }, @@ -29,6 +29,7 @@ pub enum SonarrEvent { ClearBlocklist, DeleteBlocklistItem(Option), GetBlocklist, + GetDownloads, GetEpisodeDetails(Option), GetEpisodes(Option), GetLogs(Option), @@ -44,6 +45,7 @@ impl NetworkResource for SonarrEvent { SonarrEvent::ClearBlocklist => "/blocklist/bulk", SonarrEvent::DeleteBlocklistItem(_) => "/blocklist", SonarrEvent::GetBlocklist => "/blocklist?page=1&pageSize=10000", + SonarrEvent::GetDownloads => "/queue", SonarrEvent::GetEpisodes(_) | SonarrEvent::GetEpisodeDetails(_) => "/episode", SonarrEvent::GetLogs(_) => "/log", SonarrEvent::GetQualityProfiles => "/qualityprofile", @@ -75,6 +77,7 @@ impl<'a, 'b> Network<'a, 'b> { .await .map(SonarrSerdeable::from), SonarrEvent::GetBlocklist => self.get_sonarr_blocklist().await.map(SonarrSerdeable::from), + SonarrEvent::GetDownloads => self.get_sonarr_downloads().await.map(SonarrSerdeable::from), SonarrEvent::GetEpisodes(series_id) => self .get_episodes(series_id) .await @@ -200,6 +203,25 @@ impl<'a, 'b> Network<'a, 'b> { .await } + async fn get_sonarr_downloads(&mut self) -> Result { + info!("Fetching Sonarr downloads"); + let event = SonarrEvent::GetDownloads; + + let request_props = self + .request_props_from(event, RequestMethod::Get, None::<()>, None, None) + .await; + + self + .handle_request::<(), DownloadsResponse>(request_props, |queue_response, mut app| { + app + .data + .sonarr_data + .downloads + .set_items(queue_response.records); + }) + .await + } + async fn get_episodes(&mut self, series_id: Option) -> Result> { let event = SonarrEvent::GetEpisodes(series_id); let (id, series_id_param) = self.extract_series_id(series_id).await; diff --git a/src/network/sonarr_network_tests.rs b/src/network/sonarr_network_tests.rs index bb0b182..1f5d72a 100644 --- a/src/network/sonarr_network_tests.rs +++ b/src/network/sonarr_network_tests.rs @@ -23,8 +23,8 @@ mod test { use crate::app::App; use crate::models::servarr_data::sonarr::sonarr_data::ActiveSonarrBlock; use crate::models::sonarr_models::{ - BlocklistItem, DownloadRecord, Episode, EpisodeFile, Language, LogResponse, MediaInfo, - QualityProfile, + BlocklistItem, DownloadRecord, DownloadsResponse, Episode, EpisodeFile, Language, LogResponse, + MediaInfo, QualityProfile, }; use crate::models::sonarr_models::{BlocklistResponse, Quality}; use crate::models::sonarr_models::{QualityWrapper, SystemStatus}; @@ -142,6 +142,7 @@ mod test { #[case(SonarrEvent::DeleteBlocklistItem(None), "/blocklist")] #[case(SonarrEvent::HealthCheck, "/health")] #[case(SonarrEvent::GetBlocklist, "/blocklist?page=1&pageSize=10000")] + #[case(SonarrEvent::GetDownloads, "/queue")] #[case(SonarrEvent::GetLogs(Some(500)), "/log")] #[case(SonarrEvent::GetQualityProfiles, "/qualityprofile")] #[case(SonarrEvent::GetStatus, "/system/status")] @@ -322,6 +323,49 @@ mod test { } } + #[tokio::test] + async fn test_handle_get_sonarr_downloads_event() { + let downloads_response_json = json!({ + "records": [{ + "title": "Test Download Title", + "status": "downloading", + "id": 1, + "episodeId": 1, + "size": 3543348019u64, + "sizeleft": 1771674009, + "outputPath": "/nfs/tv/Test show/season 1/", + "indexer": "kickass torrents", + "downloadClient": "transmission", + }] + }); + let response: DownloadsResponse = + serde_json::from_value(downloads_response_json.clone()).unwrap(); + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(downloads_response_json), + None, + SonarrEvent::GetDownloads, + None, + None, + ) + .await; + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + if let SonarrSerdeable::DownloadsResponse(downloads) = network + .handle_sonarr_event(SonarrEvent::GetDownloads) + .await + .unwrap() + { + async_server.assert_async().await; + assert_eq!( + app_arc.lock().await.data.sonarr_data.downloads.items, + downloads_response().records + ); + assert_eq!(downloads, response); + } + } + #[tokio::test] async fn test_handle_get_sonarr_healthcheck_event() { let (async_server, app_arc, _server) = mock_servarr_api( @@ -1403,6 +1447,28 @@ mod test { } } + fn download_record() -> DownloadRecord { + DownloadRecord { + title: "Test Download Title".to_owned(), + status: "downloading".to_owned(), + id: 1, + episode_id: 1, + size: 3543348019, + sizeleft: 1771674009, + output_path: Some(HorizontallyScrollableText::from( + "/nfs/tv/Test show/season 1/", + )), + indexer: "kickass torrents".to_owned(), + download_client: "transmission".to_owned(), + } + } + + fn downloads_response() -> DownloadsResponse { + DownloadsResponse { + records: vec![download_record()], + } + } + fn episode() -> Episode { Episode { id: 1, From d5e6d64d0f2d373b34a2a3c0aacf140288a92ad5 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Fri, 15 Nov 2024 18:43:36 -0700 Subject: [PATCH 011/119] fix: Imported a missing macro in the panic hook --- src/main.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main.rs b/src/main.rs index 72eeae8..3db416f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -287,7 +287,7 @@ fn create_cert(cert_path: &String, servarr_name: &str) -> Certificate { #[cfg(not(debug_assertions))] fn panic_hook(info: &PanicHookInfo<'_>) { - use human_panic::{handle_dump, print_msg}; + use human_panic::{handle_dump, metadata, print_msg}; let meta = metadata!(); let file_path = handle_dump(&meta, info); From aaa4e67f4341454c54d7e245ed747a45f1bf7ac4 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Mon, 18 Nov 2024 18:47:49 -0700 Subject: [PATCH 012/119] chore: published the managarr-tui-widget to crates.io so I changed the Cargo.toml to now pull from crates.io instead of directly using the repo for the widget --- Cargo.lock | 3 ++- Cargo.toml | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 93eb84d..40c816b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1314,7 +1314,8 @@ dependencies = [ [[package]] name = "managarr-tree-widget" version = "0.24.0" -source = "git+https://github.com/Dark-Alex-17/managarr-tree-widget.git#b0d8d9f0bfdbb4f4a43f9a59806f8e84bbaf4e30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aae8e5f28f9581dcddb67e4741a96231752dafb997224cae6d42c75db29eb5af" dependencies = [ "ratatui", "unicode-width 0.2.0", diff --git a/Cargo.toml b/Cargo.toml index dd0ff7a..3563627 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -45,7 +45,7 @@ ctrlc = "3.4.5" colored = "2.1.0" async-trait = "0.1.83" dirs-next = "2.0.0" -managarr-tree-widget = { git = "https://github.com/Dark-Alex-17/managarr-tree-widget.git" } +managarr-tree-widget = "0.24.0" [dev-dependencies] assert_cmd = "2.0.16" From d8979221c80c1cc4e3ee3fd69004ed75f9aee93f Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Mon, 18 Nov 2024 19:54:42 -0700 Subject: [PATCH 013/119] feat(network): Added the GetIndexers network call for Sonarr --- src/models/servarr_data/sonarr/sonarr_data.rs | 4 +- .../servarr_data/sonarr/sonarr_data_tests.rs | 1 + src/models/sonarr_models.rs | 31 ++++++ src/network/radarr_network.rs | 4 +- src/network/radarr_network_tests.rs | 2 +- src/network/sonarr_network.rs | 22 +++- src/network/sonarr_network_tests.rs | 101 +++++++++++++++++- 7 files changed, 157 insertions(+), 8 deletions(-) diff --git a/src/models/servarr_data/sonarr/sonarr_data.rs b/src/models/servarr_data/sonarr/sonarr_data.rs index 3cb44b6..39f3b13 100644 --- a/src/models/servarr_data/sonarr/sonarr_data.rs +++ b/src/models/servarr_data/sonarr/sonarr_data.rs @@ -3,7 +3,7 @@ use chrono::{DateTime, Utc}; use strum::EnumIter; use crate::models::{ - sonarr_models::{BlocklistItem, DownloadRecord, Episode, Series}, + sonarr_models::{BlocklistItem, DownloadRecord, Episode, Indexer, Series}, stateful_list::StatefulList, stateful_table::StatefulTable, stateful_tree::StatefulTree, @@ -27,6 +27,7 @@ pub struct SonarrData { pub downloads: StatefulTable, pub episode_details_modal: Option, pub quality_profile_map: BiMap, + pub indexers: StatefulTable, } impl Default for SonarrData { @@ -42,6 +43,7 @@ impl Default for SonarrData { downloads: StatefulTable::default(), episode_details_modal: None, quality_profile_map: BiMap::new(), + indexers: StatefulTable::default(), } } } diff --git a/src/models/servarr_data/sonarr/sonarr_data_tests.rs b/src/models/servarr_data/sonarr/sonarr_data_tests.rs index 24d40ea..2f44e42 100644 --- a/src/models/servarr_data/sonarr/sonarr_data_tests.rs +++ b/src/models/servarr_data/sonarr/sonarr_data_tests.rs @@ -44,6 +44,7 @@ mod tests { assert!(sonarr_data.downloads.is_empty()); assert!(sonarr_data.episode_details_modal.is_none()); assert!(sonarr_data.quality_profile_map.is_empty()); + assert!(sonarr_data.indexers.is_empty()); } } } diff --git a/src/models/sonarr_models.rs b/src/models/sonarr_models.rs index 6e1d3b1..6b5f6d9 100644 --- a/src/models/sonarr_models.rs +++ b/src/models/sonarr_models.rs @@ -103,6 +103,35 @@ pub struct EpisodeFile { pub media_info: Option, } +#[derive(Default, Deserialize, Serialize, Debug, Clone, Eq, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct Indexer { + #[serde(deserialize_with = "super::from_i64")] + pub id: i64, + pub name: Option, + pub implementation: Option, + pub implementation_name: Option, + pub config_contract: Option, + pub supports_rss: bool, + pub supports_search: bool, + pub fields: Option>, + pub enable_rss: bool, + pub enable_automatic_search: bool, + pub enable_interactive_search: bool, + pub protocol: String, + #[serde(deserialize_with = "super::from_i64")] + pub priority: i64, + #[serde(deserialize_with = "super::from_i64")] + pub download_client_id: i64, + pub tags: Vec, +} + +#[derive(Default, Deserialize, Serialize, Debug, Clone, Eq, PartialEq)] +pub struct IndexerField { + pub name: Option, + pub value: Option, +} + #[derive(Serialize, Deserialize, Default, Debug, Hash, Clone, PartialEq, Eq, Ord, PartialOrd)] pub struct Language { pub name: String, @@ -330,6 +359,7 @@ pub enum SonarrSerdeable { DownloadsResponse(DownloadsResponse), Episode(Episode), Episodes(Vec), + Indexers(Vec), QualityProfiles(Vec), SeriesVec(Vec), SystemStatus(SystemStatus), @@ -355,6 +385,7 @@ serde_enum_from!( DownloadsResponse(DownloadsResponse), Episode(Episode), Episodes(Vec), + Indexers(Vec), QualityProfiles(Vec), SeriesVec(Vec), SystemStatus(SystemStatus), diff --git a/src/network/radarr_network.rs b/src/network/radarr_network.rs index d673623..8924dd5 100644 --- a/src/network/radarr_network.rs +++ b/src/network/radarr_network.rs @@ -193,7 +193,7 @@ impl<'a, 'b> Network<'a, 'b> { RadarrEvent::GetCollections => self.get_collections().await.map(RadarrSerdeable::from), RadarrEvent::GetDownloads => self.get_radarr_downloads().await.map(RadarrSerdeable::from), RadarrEvent::GetHostConfig => self.get_host_config().await.map(RadarrSerdeable::from), - RadarrEvent::GetIndexers => self.get_indexers().await.map(RadarrSerdeable::from), + RadarrEvent::GetIndexers => self.get_radarr_indexers().await.map(RadarrSerdeable::from), RadarrEvent::GetLogs(events) => self .get_radarr_logs(events) .await @@ -1395,7 +1395,7 @@ impl<'a, 'b> Network<'a, 'b> { .await } - async fn get_indexers(&mut self) -> Result> { + async fn get_radarr_indexers(&mut self) -> Result> { info!("Fetching Radarr indexers"); let event = RadarrEvent::GetIndexers; diff --git a/src/network/radarr_network_tests.rs b/src/network/radarr_network_tests.rs index 2957295..bd98025 100644 --- a/src/network/radarr_network_tests.rs +++ b/src/network/radarr_network_tests.rs @@ -2261,7 +2261,7 @@ mod test { } #[tokio::test] - async fn test_handle_get_indexers_event() { + async fn test_handle_get_radarr_indexers_event() { let indexers_response_json = json!([{ "enableRss": true, "enableAutomaticSearch": true, diff --git a/src/network/sonarr_network.rs b/src/network/sonarr_network.rs index 29423df..2db80ff 100644 --- a/src/network/sonarr_network.rs +++ b/src/network/sonarr_network.rs @@ -10,8 +10,8 @@ use crate::{ models::{ servarr_data::sonarr::{modals::EpisodeDetailsModal, sonarr_data::ActiveSonarrBlock}, sonarr_models::{ - BlocklistResponse, DownloadRecord, DownloadsResponse, Episode, LogResponse, QualityProfile, - Series, SonarrSerdeable, SystemStatus, + BlocklistResponse, DownloadRecord, DownloadsResponse, Episode, Indexer, LogResponse, + QualityProfile, Series, SonarrSerdeable, SystemStatus, }, HorizontallyScrollableText, Route, Scrollable, ScrollableText, }, @@ -30,6 +30,7 @@ pub enum SonarrEvent { DeleteBlocklistItem(Option), GetBlocklist, GetDownloads, + GetIndexers, GetEpisodeDetails(Option), GetEpisodes(Option), GetLogs(Option), @@ -47,6 +48,7 @@ impl NetworkResource for SonarrEvent { SonarrEvent::GetBlocklist => "/blocklist?page=1&pageSize=10000", SonarrEvent::GetDownloads => "/queue", SonarrEvent::GetEpisodes(_) | SonarrEvent::GetEpisodeDetails(_) => "/episode", + SonarrEvent::GetIndexers => "/indexer", SonarrEvent::GetLogs(_) => "/log", SonarrEvent::GetQualityProfiles => "/qualityprofile", SonarrEvent::GetStatus => "/system/status", @@ -86,6 +88,7 @@ impl<'a, 'b> Network<'a, 'b> { .get_episode_details(episode_id) .await .map(SonarrSerdeable::from), + SonarrEvent::GetIndexers => self.get_sonarr_indexers().await.map(SonarrSerdeable::from), SonarrEvent::GetQualityProfiles => self .get_sonarr_quality_profiles() .await @@ -390,6 +393,21 @@ impl<'a, 'b> Network<'a, 'b> { .await } + async fn get_sonarr_indexers(&mut self) -> Result> { + info!("Fetching Sonarr indexers"); + let event = SonarrEvent::GetIndexers; + + let request_props = self + .request_props_from(event, RequestMethod::Get, None::<()>, None, None) + .await; + + self + .handle_request::<(), Vec>(request_props, |indexers, mut app| { + app.data.sonarr_data.indexers.set_items(indexers); + }) + .await + } + async fn get_sonarr_logs(&mut self, events: Option) -> Result { info!("Fetching Sonarr logs"); let event = SonarrEvent::GetLogs(events); diff --git a/src/network/sonarr_network_tests.rs b/src/network/sonarr_network_tests.rs index 1f5d72a..8d3b31e 100644 --- a/src/network/sonarr_network_tests.rs +++ b/src/network/sonarr_network_tests.rs @@ -23,8 +23,8 @@ mod test { use crate::app::App; use crate::models::servarr_data::sonarr::sonarr_data::ActiveSonarrBlock; use crate::models::sonarr_models::{ - BlocklistItem, DownloadRecord, DownloadsResponse, Episode, EpisodeFile, Language, LogResponse, - MediaInfo, QualityProfile, + BlocklistItem, DownloadRecord, DownloadsResponse, Episode, EpisodeFile, Indexer, IndexerField, + Language, LogResponse, MediaInfo, QualityProfile, }; use crate::models::sonarr_models::{BlocklistResponse, Quality}; use crate::models::sonarr_models::{QualityWrapper, SystemStatus}; @@ -137,6 +137,11 @@ mod test { assert_str_eq!(event.resource(), "/series"); } + #[rstest] + fn test_resource_indexer(#[values(SonarrEvent::GetIndexers)] event: SonarrEvent) { + assert_str_eq!(event.resource(), "/indexer"); + } + #[rstest] #[case(SonarrEvent::ClearBlocklist, "/blocklist/bulk")] #[case(SonarrEvent::DeleteBlocklistItem(None), "/blocklist")] @@ -646,6 +651,65 @@ mod test { } } + #[tokio::test] + async fn test_handle_get_sonarr_indexers_event() { + let indexers_response_json = json!([{ + "enableRss": true, + "enableAutomaticSearch": true, + "enableInteractiveSearch": true, + "supportsRss": true, + "supportsSearch": true, + "protocol": "torrent", + "priority": 25, + "downloadClientId": 0, + "name": "Test Indexer", + "fields": [ + { + "name": "baseUrl", + "value": "https://test.com", + }, + { + "name": "apiKey", + "value": "", + }, + { + "name": "seedCriteria.seedRatio", + "value": "1.2", + }, + ], + "implementationName": "Torznab", + "implementation": "Torznab", + "configContract": "TorznabSettings", + "tags": [1], + "id": 1 + }]); + let response: Vec = serde_json::from_value(indexers_response_json.clone()).unwrap(); + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(indexers_response_json), + None, + SonarrEvent::GetIndexers, + None, + None, + ) + .await; + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + if let SonarrSerdeable::Indexers(indexers) = network + .handle_sonarr_event(SonarrEvent::GetIndexers) + .await + .unwrap() + { + async_server.assert_async().await; + assert_eq!( + app_arc.lock().await.data.sonarr_data.indexers.items, + vec![indexer()] + ); + assert_eq!(indexers, response); + } + } + #[tokio::test] async fn test_handle_get_episodes_event_uses_provided_series_id() { let episodes_json = json!([ @@ -1499,6 +1563,39 @@ mod test { } } + fn indexer() -> Indexer { + Indexer { + enable_rss: true, + enable_automatic_search: true, + enable_interactive_search: true, + supports_rss: true, + supports_search: true, + protocol: "torrent".to_owned(), + priority: 25, + download_client_id: 0, + name: Some("Test Indexer".to_owned()), + implementation_name: Some("Torznab".to_owned()), + implementation: Some("Torznab".to_owned()), + config_contract: Some("TorznabSettings".to_owned()), + tags: vec![Number::from(1)], + id: 1, + fields: Some(vec![ + IndexerField { + name: Some("baseUrl".to_owned()), + value: Some(json!("https://test.com")), + }, + IndexerField { + name: Some("apiKey".to_owned()), + value: Some(json!("")), + }, + IndexerField { + name: Some("seedCriteria.seedRatio".to_owned()), + value: Some(json!("1.2")), + }, + ]), + } + } + fn language() -> Language { Language { name: "English".to_owned(), From f094cf5ad3c8c3ba62b100fa6db4dabed963b51a Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Mon, 18 Nov 2024 19:57:55 -0700 Subject: [PATCH 014/119] feat(cli): Added CLI support for listing Sonarr indexers --- src/cli/sonarr/list_command_handler.rs | 5 +++++ src/cli/sonarr/list_command_handler_tests.rs | 4 +++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/src/cli/sonarr/list_command_handler.rs b/src/cli/sonarr/list_command_handler.rs index 224bd09..470d97e 100644 --- a/src/cli/sonarr/list_command_handler.rs +++ b/src/cli/sonarr/list_command_handler.rs @@ -32,6 +32,8 @@ pub enum SonarrListCommand { )] series_id: i64, }, + #[command(about = "List all Sonarr indexers")] + Indexers, #[command(about = "Fetch Sonarr logs")] Logs { #[arg(long, help = "How many log events to fetch", default_value_t = 500)] @@ -84,6 +86,9 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, SonarrListCommand> for SonarrListCommandH SonarrListCommand::Episodes { series_id } => { execute_network_event!(self, SonarrEvent::GetEpisodes(Some(series_id))); } + SonarrListCommand::Indexers => { + execute_network_event!(self, SonarrEvent::GetIndexers); + } SonarrListCommand::Logs { events, output_in_log_format, diff --git a/src/cli/sonarr/list_command_handler_tests.rs b/src/cli/sonarr/list_command_handler_tests.rs index 6e01e4b..99adf6f 100644 --- a/src/cli/sonarr/list_command_handler_tests.rs +++ b/src/cli/sonarr/list_command_handler_tests.rs @@ -24,7 +24,8 @@ mod tests { #[rstest] fn test_list_commands_have_no_arg_requirements( - #[values("blocklist", "series", "downloads", "quality-profiles")] subcommand: &str, + #[values("blocklist", "series", "downloads", "quality-profiles", "indexers")] + subcommand: &str, ) { let result = Cli::command().try_get_matches_from(["managarr", "sonarr", "list", subcommand]); @@ -103,6 +104,7 @@ mod tests { #[rstest] #[case(SonarrListCommand::Blocklist, SonarrEvent::GetBlocklist)] #[case(SonarrListCommand::Downloads, SonarrEvent::GetDownloads)] + #[case(SonarrListCommand::Indexers, SonarrEvent::GetIndexers)] #[case(SonarrListCommand::QualityProfiles, SonarrEvent::GetQualityProfiles)] #[case(SonarrListCommand::Series, SonarrEvent::ListSeries)] #[tokio::test] From a012945df2401a966ee2730d0c915610147917cb Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Mon, 18 Nov 2024 20:49:07 -0700 Subject: [PATCH 015/119] feat(network): Added network support for fetching host and security configs from Sonarr --- .../indexers/indexers_handler_tests.rs | 6 +- src/handlers/radarr_handlers/indexers/mod.rs | 3 +- src/models/mod.rs | 1 + src/models/radarr_models.rs | 115 +--------------- src/models/radarr_models_tests.rs | 35 +---- src/models/servarr_data/radarr/modals.rs | 4 +- .../servarr_data/radarr/modals_tests.rs | 7 +- src/models/servarr_data/radarr/radarr_data.rs | 3 +- src/models/servarr_data/sonarr/sonarr_data.rs | 3 +- src/models/servarr_models.rs | 125 ++++++++++++++++++ src/models/servarr_models_tests.rs | 34 +++++ src/models/sonarr_models.rs | 38 ++---- src/network/radarr_network.rs | 23 ++-- src/network/radarr_network_tests.rs | 10 +- src/network/sonarr_network.rs | 42 +++++- src/network/sonarr_network_tests.rs | 82 +++++++++++- src/ui/radarr_ui/indexers/mod.rs | 2 +- 17 files changed, 328 insertions(+), 205 deletions(-) create mode 100644 src/models/servarr_models.rs create mode 100644 src/models/servarr_models_tests.rs diff --git a/src/handlers/radarr_handlers/indexers/indexers_handler_tests.rs b/src/handlers/radarr_handlers/indexers/indexers_handler_tests.rs index 1d3d59e..50d646c 100644 --- a/src/handlers/radarr_handlers/indexers/indexers_handler_tests.rs +++ b/src/handlers/radarr_handlers/indexers/indexers_handler_tests.rs @@ -9,16 +9,15 @@ mod tests { use crate::event::Key; use crate::handlers::radarr_handlers::indexers::IndexersHandler; use crate::handlers::KeyEventHandler; - use crate::models::radarr_models::Indexer; use crate::models::servarr_data::radarr::radarr_data::{ ActiveRadarrBlock, EDIT_INDEXER_BLOCKS, INDEXERS_BLOCKS, INDEXER_SETTINGS_BLOCKS, }; + use crate::models::servarr_models::Indexer; use crate::test_handler_delegation; mod test_handle_scroll_up_and_down { use rstest::rstest; - use crate::models::radarr_models::Indexer; use crate::{simple_stateful_iterable_vec, test_iterable_scroll}; use super::*; @@ -65,7 +64,6 @@ mod tests { } mod test_handle_home_end { - use crate::models::radarr_models::Indexer; use crate::{extended_stateful_iterable_vec, test_iterable_home_and_end}; use super::*; @@ -239,11 +237,11 @@ mod tests { } mod test_handle_submit { - use crate::models::radarr_models::{Indexer, IndexerField}; use crate::models::servarr_data::radarr::modals::EditIndexerModal; use crate::models::servarr_data::radarr::radarr_data::{ RadarrData, EDIT_INDEXER_NZB_SELECTION_BLOCKS, EDIT_INDEXER_TORRENT_SELECTION_BLOCKS, }; + use crate::models::servarr_models::{Indexer, IndexerField}; use bimap::BiMap; use pretty_assertions::assert_eq; use serde_json::{Number, Value}; diff --git a/src/handlers/radarr_handlers/indexers/mod.rs b/src/handlers/radarr_handlers/indexers/mod.rs index f8d00fc..84d4832 100644 --- a/src/handlers/radarr_handlers/indexers/mod.rs +++ b/src/handlers/radarr_handlers/indexers/mod.rs @@ -10,7 +10,8 @@ use crate::models::servarr_data::radarr::radarr_data::{ ActiveRadarrBlock, EDIT_INDEXER_NZB_SELECTION_BLOCKS, EDIT_INDEXER_TORRENT_SELECTION_BLOCKS, INDEXERS_BLOCKS, INDEXER_SETTINGS_SELECTION_BLOCKS, }; -use crate::models::{BlockSelectionState, Scrollable}; +use crate::models::BlockSelectionState; +use crate::models::Scrollable; use crate::network::radarr_network::RadarrEvent; mod edit_indexer_handler; diff --git a/src/models/mod.rs b/src/models/mod.rs index 14abfbb..5541424 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -10,6 +10,7 @@ use servarr_data::sonarr::sonarr_data::ActiveSonarrBlock; use sonarr_models::SonarrSerdeable; pub mod radarr_models; pub mod servarr_data; +pub mod servarr_models; pub mod sonarr_models; pub mod stateful_list; pub mod stateful_table; diff --git a/src/models/radarr_models.rs b/src/models/radarr_models.rs index 225d338..5934cfc 100644 --- a/src/models/radarr_models.rs +++ b/src/models/radarr_models.rs @@ -9,6 +9,7 @@ use strum_macros::EnumIter; use crate::{models::HorizontallyScrollableText, serde_enum_from}; +use super::servarr_models::{HostConfig, Indexer, SecurityConfig}; use super::Serdeable; #[cfg(test)] @@ -57,44 +58,6 @@ pub struct AddRootFolderBody { pub path: String, } -#[derive(Serialize, Deserialize, Default, PartialEq, Eq, Clone, Copy, Debug, ValueEnum)] -#[serde(rename_all = "camelCase")] -pub enum AuthenticationMethod { - #[default] - Basic, - Forms, - None, -} - -impl Display for AuthenticationMethod { - fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { - let authentication_method = match self { - AuthenticationMethod::Basic => "basic", - AuthenticationMethod::Forms => "forms", - AuthenticationMethod::None => "none", - }; - write!(f, "{authentication_method}") - } -} - -#[derive(Serialize, Deserialize, Default, PartialEq, Eq, Clone, Copy, Debug, ValueEnum)] -#[serde(rename_all = "camelCase")] -pub enum AuthenticationRequired { - Enabled, - #[default] - DisabledForLocalAddresses, -} - -impl Display for AuthenticationRequired { - fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { - let authentication_required = match self { - AuthenticationRequired::Enabled => "enabled", - AuthenticationRequired::DisabledForLocalAddresses => "disabledForLocalAddresses", - }; - write!(f, "{authentication_required}") - } -} - #[derive(Default, Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] pub struct BlocklistResponse { pub records: Vec, @@ -123,26 +86,6 @@ pub struct BlocklistItemMovie { pub title: HorizontallyScrollableText, } -#[derive(Serialize, Deserialize, Default, PartialEq, Eq, Clone, Copy, Debug, ValueEnum)] -#[serde(rename_all = "camelCase")] -pub enum CertificateValidation { - #[default] - Enabled, - DisabledForLocalAddresses, - Disabled, -} - -impl Display for CertificateValidation { - fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { - let certificate_validation = match self { - CertificateValidation::Enabled => "enabled", - CertificateValidation::DisabledForLocalAddresses => "disabledForLocalAddresses", - CertificateValidation::Disabled => "disabled", - }; - write!(f, "{certificate_validation}") - } -} - #[derive(Serialize, Deserialize, Derivative, Default, Clone, Debug, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct Collection { @@ -281,51 +224,6 @@ pub struct EditMovieParams { pub clear_tags: bool, } -#[derive(Default, Deserialize, Serialize, Debug, Clone, Eq, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct HostConfig { - pub bind_address: HorizontallyScrollableText, - #[serde(deserialize_with = "super::from_i64")] - pub port: i64, - pub url_base: Option, - pub instance_name: Option, - pub application_url: Option, - pub enable_ssl: bool, - #[serde(deserialize_with = "super::from_i64")] - pub ssl_port: i64, - pub ssl_cert_path: Option, - pub ssl_cert_password: Option, -} - -#[derive(Default, Deserialize, Serialize, Debug, Clone, Eq, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct Indexer { - #[serde(deserialize_with = "super::from_i64")] - pub id: i64, - pub name: Option, - pub implementation: Option, - pub implementation_name: Option, - pub config_contract: Option, - pub supports_rss: bool, - pub supports_search: bool, - pub fields: Option>, - pub enable_rss: bool, - pub enable_automatic_search: bool, - pub enable_interactive_search: bool, - pub protocol: String, - #[serde(deserialize_with = "super::from_i64")] - pub priority: i64, - #[serde(deserialize_with = "super::from_i64")] - pub download_client_id: i64, - pub tags: Vec, -} - -#[derive(Default, Deserialize, Serialize, Debug, Clone, Eq, PartialEq)] -pub struct IndexerField { - pub name: Option, - pub value: Option, -} - #[derive(Default, Deserialize, Serialize, Debug, Clone, Eq, PartialEq)] #[serde(rename_all = "camelCase")] pub struct IndexerSettings { @@ -634,17 +532,6 @@ pub struct RootFolder { pub unmapped_folders: Option>, } -#[derive(Default, Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct SecurityConfig { - pub authentication_method: AuthenticationMethod, - pub authentication_required: AuthenticationRequired, - pub username: String, - pub password: Option, - pub api_key: String, - pub certificate_validation: CertificateValidation, -} - #[derive(Default, Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct SystemStatus { diff --git a/src/models/radarr_models_tests.rs b/src/models/radarr_models_tests.rs index 4553f22..e562eb1 100644 --- a/src/models/radarr_models_tests.rs +++ b/src/models/radarr_models_tests.rs @@ -5,41 +5,14 @@ mod tests { use crate::models::{ radarr_models::{ - AddMovieSearchResult, AuthenticationMethod, AuthenticationRequired, BlocklistItem, - BlocklistResponse, CertificateValidation, Collection, Credit, DiskSpace, DownloadRecord, - DownloadsResponse, Indexer, IndexerSettings, IndexerTestResult, Log, LogResponse, - MinimumAvailability, Monitor, Movie, MovieHistoryItem, QualityProfile, QueueEvent, - RadarrSerdeable, Release, RootFolder, SystemStatus, Tag, Task, TaskName, Update, + AddMovieSearchResult, BlocklistItem, BlocklistResponse, Collection, Credit, DiskSpace, + DownloadRecord, DownloadsResponse, Indexer, IndexerSettings, IndexerTestResult, Log, + LogResponse, MinimumAvailability, Monitor, Movie, MovieHistoryItem, QualityProfile, + QueueEvent, RadarrSerdeable, Release, RootFolder, SystemStatus, Tag, Task, TaskName, Update, }, Serdeable, }; - #[test] - fn test_authentication_method_display() { - assert_str_eq!(AuthenticationMethod::Basic.to_string(), "basic"); - assert_str_eq!(AuthenticationMethod::Forms.to_string(), "forms"); - assert_str_eq!(AuthenticationMethod::None.to_string(), "none"); - } - - #[test] - fn test_authentication_required_display() { - assert_str_eq!(AuthenticationRequired::Enabled.to_string(), "enabled"); - assert_str_eq!( - AuthenticationRequired::DisabledForLocalAddresses.to_string(), - "disabledForLocalAddresses" - ); - } - - #[test] - fn test_certificate_validation_display() { - assert_str_eq!(CertificateValidation::Enabled.to_string(), "enabled"); - assert_str_eq!( - CertificateValidation::DisabledForLocalAddresses.to_string(), - "disabledForLocalAddresses" - ); - assert_str_eq!(CertificateValidation::Disabled.to_string(), "disabled"); - } - #[test] fn test_task_name_display() { assert_str_eq!( diff --git a/src/models/servarr_data/radarr/modals.rs b/src/models/servarr_data/radarr/modals.rs index 074991c..93ef321 100644 --- a/src/models/servarr_data/radarr/modals.rs +++ b/src/models/servarr_data/radarr/modals.rs @@ -1,10 +1,10 @@ use strum::IntoEnumIterator; use crate::models::radarr_models::{ - Collection, Credit, Indexer, MinimumAvailability, Monitor, Movie, MovieHistoryItem, Release, - RootFolder, + Collection, Credit, MinimumAvailability, Monitor, Movie, MovieHistoryItem, Release, RootFolder, }; use crate::models::servarr_data::radarr::radarr_data::RadarrData; +use crate::models::servarr_models::Indexer; use crate::models::stateful_list::StatefulList; use crate::models::stateful_table::StatefulTable; use crate::models::{HorizontallyScrollableText, ScrollableText}; diff --git a/src/models/servarr_data/radarr/modals_tests.rs b/src/models/servarr_data/radarr/modals_tests.rs index 63479aa..5da0298 100644 --- a/src/models/servarr_data/radarr/modals_tests.rs +++ b/src/models/servarr_data/radarr/modals_tests.rs @@ -1,13 +1,12 @@ #[cfg(test)] mod test { - use crate::models::radarr_models::{ - Collection, Indexer, IndexerField, MinimumAvailability, Monitor, Movie, RootFolder, - }; + use crate::models::radarr_models::{Collection, MinimumAvailability, Monitor, Movie, RootFolder}; use crate::models::servarr_data::radarr::modals::{ AddMovieModal, EditCollectionModal, EditIndexerModal, EditMovieModal, }; use crate::models::servarr_data::radarr::radarr_data::radarr_test_utils::utils::create_test_radarr_data; use crate::models::servarr_data::radarr::radarr_data::RadarrData; + use crate::models::servarr_models::{Indexer, IndexerField}; use crate::models::stateful_table::StatefulTable; use bimap::BiMap; use pretty_assertions::{assert_eq, assert_str_eq}; @@ -17,6 +16,8 @@ mod test { #[rstest] fn test_edit_indexer_modal_from_radarr_data(#[values(true, false)] seed_ratio_present: bool) { + use crate::models::servarr_models::{Indexer, IndexerField}; + let mut radarr_data = RadarrData { tags_map: BiMap::from_iter([(1, "usenet".to_owned()), (2, "test".to_owned())]), ..RadarrData::default() diff --git a/src/models/servarr_data/radarr/radarr_data.rs b/src/models/servarr_data/radarr/radarr_data.rs index e422670..e6ca06f 100644 --- a/src/models/servarr_data/radarr/radarr_data.rs +++ b/src/models/servarr_data/radarr/radarr_data.rs @@ -7,12 +7,13 @@ use crate::app::radarr::radarr_context_clues::{ }; use crate::models::radarr_models::{ AddMovieSearchResult, BlocklistItem, Collection, CollectionMovie, DiskSpace, DownloadRecord, - Indexer, IndexerSettings, Movie, QueueEvent, RootFolder, Task, + IndexerSettings, Movie, QueueEvent, RootFolder, Task, }; use crate::models::servarr_data::radarr::modals::{ AddMovieModal, EditCollectionModal, EditIndexerModal, EditMovieModal, IndexerTestResultModalItem, MovieDetailsModal, }; +use crate::models::servarr_models::Indexer; use crate::models::stateful_list::StatefulList; use crate::models::stateful_table::StatefulTable; use crate::models::{ diff --git a/src/models/servarr_data/sonarr/sonarr_data.rs b/src/models/servarr_data/sonarr/sonarr_data.rs index 39f3b13..dd53909 100644 --- a/src/models/servarr_data/sonarr/sonarr_data.rs +++ b/src/models/servarr_data/sonarr/sonarr_data.rs @@ -3,7 +3,8 @@ use chrono::{DateTime, Utc}; use strum::EnumIter; use crate::models::{ - sonarr_models::{BlocklistItem, DownloadRecord, Episode, Indexer, Series}, + servarr_models::Indexer, + sonarr_models::{BlocklistItem, DownloadRecord, Episode, Series}, stateful_list::StatefulList, stateful_table::StatefulTable, stateful_tree::StatefulTree, diff --git a/src/models/servarr_models.rs b/src/models/servarr_models.rs new file mode 100644 index 0000000..8dca55b --- /dev/null +++ b/src/models/servarr_models.rs @@ -0,0 +1,125 @@ +use std::fmt::{Display, Formatter, Result}; + +use clap::ValueEnum; +use serde::{Deserialize, Serialize}; +use serde_json::{Number, Value}; + +use super::HorizontallyScrollableText; + +#[cfg(test)] +#[path = "servarr_models_tests.rs"] +mod servarr_models_tests; + +#[derive(Serialize, Deserialize, Default, PartialEq, Eq, Clone, Copy, Debug, ValueEnum)] +#[serde(rename_all = "camelCase")] +pub enum AuthenticationMethod { + #[default] + Basic, + Forms, + None, +} + +impl Display for AuthenticationMethod { + fn fmt(&self, f: &mut Formatter<'_>) -> Result { + let authentication_method = match self { + AuthenticationMethod::Basic => "basic", + AuthenticationMethod::Forms => "forms", + AuthenticationMethod::None => "none", + }; + write!(f, "{authentication_method}") + } +} + +#[derive(Serialize, Deserialize, Default, PartialEq, Eq, Clone, Copy, Debug, ValueEnum)] +#[serde(rename_all = "camelCase")] +pub enum AuthenticationRequired { + Enabled, + #[default] + DisabledForLocalAddresses, +} + +impl Display for AuthenticationRequired { + fn fmt(&self, f: &mut Formatter<'_>) -> Result { + let authentication_required = match self { + AuthenticationRequired::Enabled => "enabled", + AuthenticationRequired::DisabledForLocalAddresses => "disabledForLocalAddresses", + }; + write!(f, "{authentication_required}") + } +} + +#[derive(Serialize, Deserialize, Default, PartialEq, Eq, Clone, Copy, Debug, ValueEnum)] +#[serde(rename_all = "camelCase")] +pub enum CertificateValidation { + #[default] + Enabled, + DisabledForLocalAddresses, + Disabled, +} + +impl Display for CertificateValidation { + fn fmt(&self, f: &mut Formatter<'_>) -> Result { + let certificate_validation = match self { + CertificateValidation::Enabled => "enabled", + CertificateValidation::DisabledForLocalAddresses => "disabledForLocalAddresses", + CertificateValidation::Disabled => "disabled", + }; + write!(f, "{certificate_validation}") + } +} + +#[derive(Default, Deserialize, Serialize, Debug, Clone, Eq, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct HostConfig { + pub bind_address: HorizontallyScrollableText, + #[serde(deserialize_with = "super::from_i64")] + pub port: i64, + pub url_base: Option, + pub instance_name: Option, + pub application_url: Option, + pub enable_ssl: bool, + #[serde(deserialize_with = "super::from_i64")] + pub ssl_port: i64, + pub ssl_cert_path: Option, + pub ssl_cert_password: Option, +} + +#[derive(Default, Deserialize, Serialize, Debug, Clone, Eq, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct Indexer { + #[serde(deserialize_with = "super::from_i64")] + pub id: i64, + pub name: Option, + pub implementation: Option, + pub implementation_name: Option, + pub config_contract: Option, + pub supports_rss: bool, + pub supports_search: bool, + pub fields: Option>, + pub enable_rss: bool, + pub enable_automatic_search: bool, + pub enable_interactive_search: bool, + pub protocol: String, + #[serde(deserialize_with = "super::from_i64")] + pub priority: i64, + #[serde(deserialize_with = "super::from_i64")] + pub download_client_id: i64, + pub tags: Vec, +} + +#[derive(Default, Deserialize, Serialize, Debug, Clone, Eq, PartialEq)] +pub struct IndexerField { + pub name: Option, + pub value: Option, +} + +#[derive(Default, Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct SecurityConfig { + pub authentication_method: AuthenticationMethod, + pub authentication_required: AuthenticationRequired, + pub username: String, + pub password: Option, + pub api_key: String, + pub certificate_validation: CertificateValidation, +} diff --git a/src/models/servarr_models_tests.rs b/src/models/servarr_models_tests.rs new file mode 100644 index 0000000..8b1468d --- /dev/null +++ b/src/models/servarr_models_tests.rs @@ -0,0 +1,34 @@ +#[cfg(test)] +mod tests { + use pretty_assertions::assert_str_eq; + + use crate::models::servarr_models::{ + AuthenticationMethod, AuthenticationRequired, CertificateValidation, + }; + + #[test] + fn test_authentication_method_display() { + assert_str_eq!(AuthenticationMethod::Basic.to_string(), "basic"); + assert_str_eq!(AuthenticationMethod::Forms.to_string(), "forms"); + assert_str_eq!(AuthenticationMethod::None.to_string(), "none"); + } + + #[test] + fn test_authentication_required_display() { + assert_str_eq!(AuthenticationRequired::Enabled.to_string(), "enabled"); + assert_str_eq!( + AuthenticationRequired::DisabledForLocalAddresses.to_string(), + "disabledForLocalAddresses" + ); + } + + #[test] + fn test_certificate_validation_display() { + assert_str_eq!(CertificateValidation::Enabled.to_string(), "enabled"); + assert_str_eq!( + CertificateValidation::DisabledForLocalAddresses.to_string(), + "disabledForLocalAddresses" + ); + assert_str_eq!(CertificateValidation::Disabled.to_string(), "disabled"); + } +} diff --git a/src/models/sonarr_models.rs b/src/models/sonarr_models.rs index 6b5f6d9..03f6937 100644 --- a/src/models/sonarr_models.rs +++ b/src/models/sonarr_models.rs @@ -9,7 +9,10 @@ use strum::EnumIter; use crate::serde_enum_from; -use super::{HorizontallyScrollableText, Serdeable}; +use super::{ + servarr_models::{HostConfig, Indexer, SecurityConfig}, + HorizontallyScrollableText, Serdeable, +}; #[cfg(test)] #[path = "sonarr_models_tests.rs"] @@ -103,35 +106,6 @@ pub struct EpisodeFile { pub media_info: Option, } -#[derive(Default, Deserialize, Serialize, Debug, Clone, Eq, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct Indexer { - #[serde(deserialize_with = "super::from_i64")] - pub id: i64, - pub name: Option, - pub implementation: Option, - pub implementation_name: Option, - pub config_contract: Option, - pub supports_rss: bool, - pub supports_search: bool, - pub fields: Option>, - pub enable_rss: bool, - pub enable_automatic_search: bool, - pub enable_interactive_search: bool, - pub protocol: String, - #[serde(deserialize_with = "super::from_i64")] - pub priority: i64, - #[serde(deserialize_with = "super::from_i64")] - pub download_client_id: i64, - pub tags: Vec, -} - -#[derive(Default, Deserialize, Serialize, Debug, Clone, Eq, PartialEq)] -pub struct IndexerField { - pub name: Option, - pub value: Option, -} - #[derive(Serialize, Deserialize, Default, Debug, Hash, Clone, PartialEq, Eq, Ord, PartialOrd)] pub struct Language { pub name: String, @@ -359,8 +333,10 @@ pub enum SonarrSerdeable { DownloadsResponse(DownloadsResponse), Episode(Episode), Episodes(Vec), + HostConfig(HostConfig), Indexers(Vec), QualityProfiles(Vec), + SecurityConfig(SecurityConfig), SeriesVec(Vec), SystemStatus(SystemStatus), BlocklistResponse(BlocklistResponse), @@ -385,8 +361,10 @@ serde_enum_from!( DownloadsResponse(DownloadsResponse), Episode(Episode), Episodes(Vec), + HostConfig(HostConfig), Indexers(Vec), QualityProfiles(Vec), + SecurityConfig(SecurityConfig), SeriesVec(Vec), SystemStatus(SystemStatus), BlocklistResponse(BlocklistResponse), diff --git a/src/network/radarr_network.rs b/src/network/radarr_network.rs index 8924dd5..99490ad 100644 --- a/src/network/radarr_network.rs +++ b/src/network/radarr_network.rs @@ -9,16 +9,17 @@ use urlencoding::encode; use crate::models::radarr_models::{ AddMovieBody, AddMovieSearchResult, AddOptions, AddRootFolderBody, BlocklistResponse, Collection, CollectionMovie, CommandBody, Credit, CreditType, DeleteMovieParams, DiskSpace, DownloadRecord, - DownloadsResponse, EditCollectionParams, EditIndexerParams, EditMovieParams, HostConfig, Indexer, - IndexerSettings, IndexerTestResult, LogResponse, Movie, MovieCommandBody, MovieHistoryItem, - QualityProfile, QueueEvent, RadarrSerdeable, Release, ReleaseDownloadBody, RootFolder, - SecurityConfig, SystemStatus, Tag, Task, TaskName, Update, + DownloadsResponse, EditCollectionParams, EditIndexerParams, EditMovieParams, IndexerSettings, + IndexerTestResult, LogResponse, Movie, MovieCommandBody, MovieHistoryItem, QualityProfile, + QueueEvent, RadarrSerdeable, Release, ReleaseDownloadBody, RootFolder, SystemStatus, Tag, Task, + TaskName, Update, }; use crate::models::servarr_data::radarr::modals::{ AddMovieModal, EditCollectionModal, EditIndexerModal, EditMovieModal, IndexerTestResultModalItem, MovieDetailsModal, }; use crate::models::servarr_data::radarr::radarr_data::ActiveRadarrBlock; +use crate::models::servarr_models::{HostConfig, Indexer, SecurityConfig}; use crate::models::stateful_table::StatefulTable; use crate::models::{HorizontallyScrollableText, Route, Scrollable, ScrollableText}; use crate::network::{Network, NetworkEvent, RequestMethod}; @@ -192,7 +193,10 @@ impl<'a, 'b> Network<'a, 'b> { RadarrEvent::GetBlocklist => self.get_radarr_blocklist().await.map(RadarrSerdeable::from), RadarrEvent::GetCollections => self.get_collections().await.map(RadarrSerdeable::from), RadarrEvent::GetDownloads => self.get_radarr_downloads().await.map(RadarrSerdeable::from), - RadarrEvent::GetHostConfig => self.get_host_config().await.map(RadarrSerdeable::from), + RadarrEvent::GetHostConfig => self + .get_radarr_host_config() + .await + .map(RadarrSerdeable::from), RadarrEvent::GetIndexers => self.get_radarr_indexers().await.map(RadarrSerdeable::from), RadarrEvent::GetLogs(events) => self .get_radarr_logs(events) @@ -220,7 +224,10 @@ impl<'a, 'b> Network<'a, 'b> { self.get_releases(movie_id).await.map(RadarrSerdeable::from) } RadarrEvent::GetRootFolders => self.get_root_folders().await.map(RadarrSerdeable::from), - RadarrEvent::GetSecurityConfig => self.get_security_config().await.map(RadarrSerdeable::from), + RadarrEvent::GetSecurityConfig => self + .get_radarr_security_config() + .await + .map(RadarrSerdeable::from), RadarrEvent::GetStatus => self.get_radarr_status().await.map(RadarrSerdeable::from), RadarrEvent::GetTags => self.get_tags().await.map(RadarrSerdeable::from), RadarrEvent::GetTasks => self.get_tasks().await.map(RadarrSerdeable::from), @@ -1382,7 +1389,7 @@ impl<'a, 'b> Network<'a, 'b> { .await } - async fn get_host_config(&mut self) -> Result { + async fn get_radarr_host_config(&mut self) -> Result { info!("Fetching Radarr host config"); let event = RadarrEvent::GetHostConfig; @@ -1788,7 +1795,7 @@ impl<'a, 'b> Network<'a, 'b> { .await } - async fn get_security_config(&mut self) -> Result { + async fn get_radarr_security_config(&mut self) -> Result { info!("Fetching Radarr security config"); let event = RadarrEvent::GetSecurityConfig; diff --git a/src/network/radarr_network_tests.rs b/src/network/radarr_network_tests.rs index bd98025..d4e5344 100644 --- a/src/network/radarr_network_tests.rs +++ b/src/network/radarr_network_tests.rs @@ -15,11 +15,11 @@ mod test { use crate::app::ServarrConfig; use crate::models::radarr_models::{ - BlocklistItem, BlocklistItemMovie, CollectionMovie, IndexerField, Language, MediaInfo, - MinimumAvailability, Monitor, MovieCollection, MovieFile, Quality, QualityWrapper, Rating, - RatingsList, + BlocklistItem, BlocklistItemMovie, CollectionMovie, Language, MediaInfo, MinimumAvailability, + Monitor, MovieCollection, MovieFile, Quality, QualityWrapper, Rating, RatingsList, }; use crate::models::servarr_data::radarr::radarr_data::ActiveRadarrBlock; + use crate::models::servarr_models::{HostConfig, IndexerField}; use crate::models::stateful_table::SortOption; use crate::models::HorizontallyScrollableText; use crate::network::network_tests::test_utils::mock_servarr_api; @@ -2225,7 +2225,7 @@ mod test { } #[tokio::test] - async fn test_handle_get_host_config_event() { + async fn test_handle_get_radarr_host_config_event() { let host_config_response = json!({ "bindAddress": "*", "port": 7878, @@ -2905,7 +2905,7 @@ mod test { } #[tokio::test] - async fn test_handle_get_security_config_event() { + async fn test_handle_get_radarr_security_config_event() { let security_config_response = json!({ "authenticationMethod": "forms", "authenticationRequired": "disabledForLocalAddresses", diff --git a/src/network/sonarr_network.rs b/src/network/sonarr_network.rs index 2db80ff..25aedef 100644 --- a/src/network/sonarr_network.rs +++ b/src/network/sonarr_network.rs @@ -9,9 +9,10 @@ use serde_json::{json, Value}; use crate::{ models::{ servarr_data::sonarr::{modals::EpisodeDetailsModal, sonarr_data::ActiveSonarrBlock}, + servarr_models::{HostConfig, Indexer, SecurityConfig}, sonarr_models::{ - BlocklistResponse, DownloadRecord, DownloadsResponse, Episode, Indexer, LogResponse, - QualityProfile, Series, SonarrSerdeable, SystemStatus, + BlocklistResponse, DownloadRecord, DownloadsResponse, Episode, LogResponse, QualityProfile, + Series, SonarrSerdeable, SystemStatus, }, HorizontallyScrollableText, Route, Scrollable, ScrollableText, }, @@ -30,11 +31,13 @@ pub enum SonarrEvent { DeleteBlocklistItem(Option), GetBlocklist, GetDownloads, + GetHostConfig, GetIndexers, GetEpisodeDetails(Option), GetEpisodes(Option), GetLogs(Option), GetQualityProfiles, + GetSecurityConfig, GetStatus, HealthCheck, ListSeries, @@ -48,6 +51,7 @@ impl NetworkResource for SonarrEvent { SonarrEvent::GetBlocklist => "/blocklist?page=1&pageSize=10000", SonarrEvent::GetDownloads => "/queue", SonarrEvent::GetEpisodes(_) | SonarrEvent::GetEpisodeDetails(_) => "/episode", + SonarrEvent::GetHostConfig | SonarrEvent::GetSecurityConfig => "/config/host", SonarrEvent::GetIndexers => "/indexer", SonarrEvent::GetLogs(_) => "/log", SonarrEvent::GetQualityProfiles => "/qualityprofile", @@ -89,6 +93,10 @@ impl<'a, 'b> Network<'a, 'b> { .await .map(SonarrSerdeable::from), SonarrEvent::GetIndexers => self.get_sonarr_indexers().await.map(SonarrSerdeable::from), + SonarrEvent::GetHostConfig => self + .get_sonarr_host_config() + .await + .map(SonarrSerdeable::from), SonarrEvent::GetQualityProfiles => self .get_sonarr_quality_profiles() .await @@ -97,6 +105,10 @@ impl<'a, 'b> Network<'a, 'b> { .get_sonarr_logs(events) .await .map(SonarrSerdeable::from), + SonarrEvent::GetSecurityConfig => self + .get_sonarr_security_config() + .await + .map(SonarrSerdeable::from), SonarrEvent::GetStatus => self.get_sonarr_status().await.map(SonarrSerdeable::from), SonarrEvent::HealthCheck => self .get_sonarr_healthcheck() @@ -393,6 +405,19 @@ impl<'a, 'b> Network<'a, 'b> { .await } + async fn get_sonarr_host_config(&mut self) -> Result { + info!("Fetching Sonarr host config"); + let event = SonarrEvent::GetHostConfig; + + let request_props = self + .request_props_from(event, RequestMethod::Get, None::<()>, None, None) + .await; + + self + .handle_request::<(), HostConfig>(request_props, |_, _| ()) + .await + } + async fn get_sonarr_indexers(&mut self) -> Result> { info!("Fetching Sonarr indexers"); let event = SonarrEvent::GetIndexers; @@ -473,6 +498,19 @@ impl<'a, 'b> Network<'a, 'b> { .await } + async fn get_sonarr_security_config(&mut self) -> Result { + info!("Fetching Sonarr security config"); + let event = SonarrEvent::GetSecurityConfig; + + let request_props = self + .request_props_from(event, RequestMethod::Get, None::<()>, None, None) + .await; + + self + .handle_request::<(), SecurityConfig>(request_props, |_, _| ()) + .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 8d3b31e..9a0e819 100644 --- a/src/network/sonarr_network_tests.rs +++ b/src/network/sonarr_network_tests.rs @@ -22,9 +22,10 @@ mod test { use crate::app::App; use crate::models::servarr_data::sonarr::sonarr_data::ActiveSonarrBlock; + use crate::models::servarr_models::{HostConfig, Indexer, IndexerField, SecurityConfig}; use crate::models::sonarr_models::{ - BlocklistItem, DownloadRecord, DownloadsResponse, Episode, EpisodeFile, Indexer, IndexerField, - Language, LogResponse, MediaInfo, QualityProfile, + BlocklistItem, DownloadRecord, DownloadsResponse, Episode, EpisodeFile, Language, LogResponse, + MediaInfo, QualityProfile, }; use crate::models::sonarr_models::{BlocklistResponse, Quality}; use crate::models::sonarr_models::{QualityWrapper, SystemStatus}; @@ -137,6 +138,13 @@ mod test { assert_str_eq!(event.resource(), "/series"); } + #[rstest] + fn test_resource_host_config( + #[values(SonarrEvent::GetHostConfig, SonarrEvent::GetSecurityConfig)] event: SonarrEvent, + ) { + assert_str_eq!(event.resource(), "/config/host"); + } + #[rstest] fn test_resource_indexer(#[values(SonarrEvent::GetIndexers)] event: SonarrEvent) { assert_str_eq!(event.resource(), "/indexer"); @@ -651,6 +659,42 @@ mod test { } } + #[tokio::test] + async fn test_handle_get_sonarr_host_config_event() { + let host_config_response = json!({ + "bindAddress": "*", + "port": 7878, + "urlBase": "some.test.site/sonarr", + "instanceName": "Sonarr", + "applicationUrl": "https://some.test.site:7878/sonarr", + "enableSsl": true, + "sslPort": 9898, + "sslCertPath": "/app/sonarr.pfx", + "sslCertPassword": "test" + }); + let response: HostConfig = serde_json::from_value(host_config_response.clone()).unwrap(); + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(host_config_response), + None, + SonarrEvent::GetHostConfig, + None, + None, + ) + .await; + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + if let SonarrSerdeable::HostConfig(host_config) = network + .handle_sonarr_event(SonarrEvent::GetHostConfig) + .await + .unwrap() + { + async_server.assert_async().await; + assert_eq!(host_config, response); + } + } + #[tokio::test] async fn test_handle_get_sonarr_indexers_event() { let indexers_response_json = json!([{ @@ -1226,6 +1270,40 @@ mod test { assert!(app_arc.lock().await.data.sonarr_data.series.sort_asc); } + #[tokio::test] + async fn test_handle_get_sonarr_security_config_event() { + let security_config_response = json!({ + "authenticationMethod": "forms", + "authenticationRequired": "disabledForLocalAddresses", + "username": "test", + "password": "some password", + "apiKey": "someApiKey12345", + "certificateValidation": "disabledForLocalAddresses", + }); + let response: SecurityConfig = + serde_json::from_value(security_config_response.clone()).unwrap(); + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(security_config_response), + None, + SonarrEvent::GetSecurityConfig, + None, + None, + ) + .await; + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + if let SonarrSerdeable::SecurityConfig(security_config) = network + .handle_sonarr_event(SonarrEvent::GetSecurityConfig) + .await + .unwrap() + { + async_server.assert_async().await; + assert_eq!(security_config, response); + } + } + #[tokio::test] async fn test_handle_get_status_event() { let (async_server, app_arc, _server) = mock_servarr_api( diff --git a/src/ui/radarr_ui/indexers/mod.rs b/src/ui/radarr_ui/indexers/mod.rs index 93a22a8..d79c84f 100644 --- a/src/ui/radarr_ui/indexers/mod.rs +++ b/src/ui/radarr_ui/indexers/mod.rs @@ -5,8 +5,8 @@ use ratatui::widgets::{Cell, Row}; use ratatui::Frame; use crate::app::App; -use crate::models::radarr_models::Indexer; use crate::models::servarr_data::radarr::radarr_data::{ActiveRadarrBlock, INDEXERS_BLOCKS}; +use crate::models::servarr_models::Indexer; use crate::models::Route; use crate::ui::radarr_ui::indexers::edit_indexer_ui::EditIndexerUi; use crate::ui::radarr_ui::indexers::indexer_settings_ui::IndexerSettingsUi; From 4fc2d3c94b8d63f5ee14169b75fe2c18938b8b95 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Mon, 18 Nov 2024 20:59:27 -0700 Subject: [PATCH 016/119] feat(cli): Added Sonarr support for fetching host and security configs --- src/cli/sonarr/get_command_handler.rs | 10 ++++ src/cli/sonarr/get_command_handler_tests.rs | 62 +++++++++++++++++++++ src/models/servarr_models.rs | 7 ++- src/network/sonarr_network_tests.rs | 8 +-- 4 files changed, 81 insertions(+), 6 deletions(-) diff --git a/src/cli/sonarr/get_command_handler.rs b/src/cli/sonarr/get_command_handler.rs index 7e42ca9..c0356ce 100644 --- a/src/cli/sonarr/get_command_handler.rs +++ b/src/cli/sonarr/get_command_handler.rs @@ -28,6 +28,10 @@ pub enum SonarrGetCommand { )] episode_id: i64, }, + #[command(about = "Fetch the host config for your Sonarr instance")] + HostConfig, + #[command(about = "Fetch the security config for your Sonarr instance")] + SecurityConfig, #[command(about = "Get the system status")] SystemStatus, } @@ -62,6 +66,12 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, SonarrGetCommand> for SonarrGetCommandHan SonarrGetCommand::EpisodeDetails { episode_id } => { execute_network_event!(self, SonarrEvent::GetEpisodeDetails(Some(episode_id))); } + SonarrGetCommand::HostConfig => { + execute_network_event!(self, SonarrEvent::GetHostConfig); + } + SonarrGetCommand::SecurityConfig => { + execute_network_event!(self, SonarrEvent::GetSecurityConfig); + } SonarrGetCommand::SystemStatus => { execute_network_event!(self, SonarrEvent::GetStatus); } diff --git a/src/cli/sonarr/get_command_handler_tests.rs b/src/cli/sonarr/get_command_handler_tests.rs index 3f6a513..00e7a38 100644 --- a/src/cli/sonarr/get_command_handler_tests.rs +++ b/src/cli/sonarr/get_command_handler_tests.rs @@ -54,6 +54,22 @@ mod tests { assert!(result.is_ok()); } + + #[test] + fn test_get_host_config_has_no_arg_requirements() { + let result = + Cli::command().try_get_matches_from(["managarr", "sonarr", "get", "host-config"]); + + assert!(result.is_ok()); + } + + #[test] + fn test_get_security_config_has_no_arg_requirements() { + let result = + Cli::command().try_get_matches_from(["managarr", "sonarr", "get", "security-config"]); + + assert!(result.is_ok()); + } } mod handler { @@ -99,6 +115,52 @@ mod tests { assert!(result.is_ok()); } + #[tokio::test] + async fn test_handle_get_host_config_command() { + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::(SonarrEvent::GetHostConfig.into())) + .times(1) + .returning(|_| { + Ok(Serdeable::Sonarr(SonarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::default())); + let get_host_config_command = SonarrGetCommand::HostConfig; + + let result = + SonarrGetCommandHandler::with(&app_arc, get_host_config_command, &mut mock_network) + .handle() + .await; + + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_handle_get_security_config_command() { + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::(SonarrEvent::GetSecurityConfig.into())) + .times(1) + .returning(|_| { + Ok(Serdeable::Sonarr(SonarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::default())); + let get_security_config_command = SonarrGetCommand::SecurityConfig; + + let result = + SonarrGetCommandHandler::with(&app_arc, get_security_config_command, &mut mock_network) + .handle() + .await; + + assert!(result.is_ok()); + } + #[tokio::test] async fn test_handle_get_system_status_command() { let mut mock_network = MockNetworkTrait::new(); diff --git a/src/models/servarr_models.rs b/src/models/servarr_models.rs index 8dca55b..0944b74 100644 --- a/src/models/servarr_models.rs +++ b/src/models/servarr_models.rs @@ -117,8 +117,11 @@ pub struct IndexerField { #[serde(rename_all = "camelCase")] pub struct SecurityConfig { pub authentication_method: AuthenticationMethod, - pub authentication_required: AuthenticationRequired, - pub username: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub authentication_required: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub username: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub password: Option, pub api_key: String, pub certificate_validation: CertificateValidation, diff --git a/src/network/sonarr_network_tests.rs b/src/network/sonarr_network_tests.rs index 9a0e819..5fe5d46 100644 --- a/src/network/sonarr_network_tests.rs +++ b/src/network/sonarr_network_tests.rs @@ -964,7 +964,7 @@ mod test { async fn test_handle_get_sonarr_logs_event() { let expected_logs = vec![ HorizontallyScrollableText::from( - "2023-05-20 21:29:16 UTC|FATAL|RadarrError|Some.Big.Bad.Exception|test exception", + "2023-05-20 21:29:16 UTC|FATAL|SonarrError|Some.Big.Bad.Exception|test exception", ), HorizontallyScrollableText::from("2023-05-20 21:29:16 UTC|INFO|TestLogger|test message"), ]; @@ -985,7 +985,7 @@ mod test { { "time": "2023-05-20T21:29:16Z", "level": "fatal", - "logger": "RadarrError", + "logger": "SonarrError", "exception": "test exception", "exceptionType": "Some.Big.Bad.Exception", "id": 2 @@ -1032,7 +1032,7 @@ mod test { async fn test_handle_get_sonarr_logs_event_uses_provided_events() { let expected_logs = vec![ HorizontallyScrollableText::from( - "2023-05-20 21:29:16 UTC|FATAL|RadarrError|Some.Big.Bad.Exception|test exception", + "2023-05-20 21:29:16 UTC|FATAL|SonarrError|Some.Big.Bad.Exception|test exception", ), HorizontallyScrollableText::from("2023-05-20 21:29:16 UTC|INFO|TestLogger|test message"), ]; @@ -1053,7 +1053,7 @@ mod test { { "time": "2023-05-20T21:29:16Z", "level": "fatal", - "logger": "RadarrError", + "logger": "SonarrError", "exception": "test exception", "exceptionType": "Some.Big.Bad.Exception", "id": 2 From 7870bb4b5b17c3ac9130e1036e9ec3cc17fad81f Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Mon, 18 Nov 2024 21:19:20 -0700 Subject: [PATCH 017/119] feat(network): Added netwwork support for fetching all indexer settings for Sonarr --- src/models/radarr_models_tests.rs | 28 +++++++++ src/models/servarr_data/sonarr/sonarr_data.rs | 4 +- .../servarr_data/sonarr/sonarr_data_tests.rs | 1 + src/models/sonarr_models.rs | 17 ++++++ src/models/sonarr_models_tests.rs | 60 ++++++++++++++++++- src/network/radarr_network.rs | 4 +- src/network/sonarr_network.rs | 31 +++++++++- 7 files changed, 137 insertions(+), 8 deletions(-) diff --git a/src/models/radarr_models_tests.rs b/src/models/radarr_models_tests.rs index e562eb1..90ce5e2 100644 --- a/src/models/radarr_models_tests.rs +++ b/src/models/radarr_models_tests.rs @@ -10,6 +10,7 @@ mod tests { LogResponse, MinimumAvailability, Monitor, Movie, MovieHistoryItem, QualityProfile, QueueEvent, RadarrSerdeable, Release, RootFolder, SystemStatus, Tag, Task, TaskName, Update, }, + servarr_models::{HostConfig, SecurityConfig}, Serdeable, }; @@ -178,6 +179,18 @@ mod tests { assert_eq!(radarr_serdeable, RadarrSerdeable::DiskSpaces(disk_spaces)); } + #[test] + fn test_radarr_serdeable_from_host_config() { + let host_config = HostConfig { + port: 1234, + ..HostConfig::default() + }; + + let radarr_serdeable: RadarrSerdeable = host_config.clone().into(); + + assert_eq!(radarr_serdeable, RadarrSerdeable::HostConfig(host_config)); + } + #[test] fn test_radarr_serdeable_from_downloads_response() { let downloads_response = DownloadsResponse { @@ -326,6 +339,21 @@ mod tests { assert_eq!(radarr_serdeable, RadarrSerdeable::RootFolders(root_folders)); } + #[test] + fn test_radarr_serdeable_from_security_config() { + let security_config = SecurityConfig { + username: Some("Test".to_owned()), + ..SecurityConfig::default() + }; + + let radarr_serdeable: RadarrSerdeable = security_config.clone().into(); + + assert_eq!( + radarr_serdeable, + RadarrSerdeable::SecurityConfig(security_config) + ); + } + #[test] fn test_radarr_serdeable_from_system_status() { let system_status = SystemStatus { diff --git a/src/models/servarr_data/sonarr/sonarr_data.rs b/src/models/servarr_data/sonarr/sonarr_data.rs index dd53909..b2eeee9 100644 --- a/src/models/servarr_data/sonarr/sonarr_data.rs +++ b/src/models/servarr_data/sonarr/sonarr_data.rs @@ -4,7 +4,7 @@ use strum::EnumIter; use crate::models::{ servarr_models::Indexer, - sonarr_models::{BlocklistItem, DownloadRecord, Episode, Series}, + sonarr_models::{BlocklistItem, DownloadRecord, Episode, IndexerSettings, Series}, stateful_list::StatefulList, stateful_table::StatefulTable, stateful_tree::StatefulTree, @@ -29,6 +29,7 @@ pub struct SonarrData { pub episode_details_modal: Option, pub quality_profile_map: BiMap, pub indexers: StatefulTable, + pub indexer_settings: Option, } impl Default for SonarrData { @@ -45,6 +46,7 @@ impl Default for SonarrData { episode_details_modal: None, quality_profile_map: BiMap::new(), indexers: StatefulTable::default(), + indexer_settings: None, } } } diff --git a/src/models/servarr_data/sonarr/sonarr_data_tests.rs b/src/models/servarr_data/sonarr/sonarr_data_tests.rs index 2f44e42..66ba96c 100644 --- a/src/models/servarr_data/sonarr/sonarr_data_tests.rs +++ b/src/models/servarr_data/sonarr/sonarr_data_tests.rs @@ -45,6 +45,7 @@ mod tests { assert!(sonarr_data.episode_details_modal.is_none()); assert!(sonarr_data.quality_profile_map.is_empty()); assert!(sonarr_data.indexers.is_empty()); + assert!(sonarr_data.indexer_settings.is_none()); } } } diff --git a/src/models/sonarr_models.rs b/src/models/sonarr_models.rs index 03f6937..cdec965 100644 --- a/src/models/sonarr_models.rs +++ b/src/models/sonarr_models.rs @@ -106,6 +106,21 @@ pub struct EpisodeFile { pub media_info: Option, } +#[derive(Default, Serialize, Deserialize, Clone, Debug, Eq, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct IndexerSettings { + #[serde(deserialize_with = "super::from_i64")] + pub id: i64, + #[serde(deserialize_with = "super::from_i64")] + pub mimimum_age: i64, + #[serde(deserialize_with = "super::from_i64")] + pub retention: i64, + #[serde(deserialize_with = "super::from_i64")] + pub maximum_size: i64, + #[serde(deserialize_with = "super::from_i64")] + pub rss_sync_interval: i64, +} + #[derive(Serialize, Deserialize, Default, Debug, Hash, Clone, PartialEq, Eq, Ord, PartialOrd)] pub struct Language { pub name: String, @@ -334,6 +349,7 @@ pub enum SonarrSerdeable { Episode(Episode), Episodes(Vec), HostConfig(HostConfig), + IndexerSettings(IndexerSettings), Indexers(Vec), QualityProfiles(Vec), SecurityConfig(SecurityConfig), @@ -362,6 +378,7 @@ serde_enum_from!( Episode(Episode), Episodes(Vec), HostConfig(HostConfig), + IndexerSettings(IndexerSettings), Indexers(Vec), QualityProfiles(Vec), SecurityConfig(SecurityConfig), diff --git a/src/models/sonarr_models_tests.rs b/src/models/sonarr_models_tests.rs index 424e7f0..d561b7f 100644 --- a/src/models/sonarr_models_tests.rs +++ b/src/models/sonarr_models_tests.rs @@ -4,9 +4,11 @@ mod tests { use serde_json::json; use crate::models::{ + servarr_models::{HostConfig, Indexer, SecurityConfig}, sonarr_models::{ - BlocklistItem, BlocklistResponse, DownloadRecord, DownloadsResponse, Episode, Log, - LogResponse, QualityProfile, Series, SeriesStatus, SeriesType, SonarrSerdeable, SystemStatus, + BlocklistItem, BlocklistResponse, DownloadRecord, DownloadsResponse, Episode, + IndexerSettings, Log, LogResponse, QualityProfile, Series, SeriesStatus, SeriesType, + SonarrSerdeable, SystemStatus, }, Serdeable, }; @@ -101,6 +103,45 @@ mod tests { assert_eq!(sonarr_serdeable, SonarrSerdeable::Episodes(episodes)); } + #[test] + fn test_sonarr_serdeable_from_host_config() { + let host_config = HostConfig { + port: 1234, + ..HostConfig::default() + }; + + let sonarr_serdeable: SonarrSerdeable = host_config.clone().into(); + + assert_eq!(sonarr_serdeable, SonarrSerdeable::HostConfig(host_config)); + } + + #[test] + fn test_sonarr_serdeable_from_indexers() { + let indexers = vec![Indexer { + id: 1, + ..Indexer::default() + }]; + + let sonarr_serdeable: SonarrSerdeable = indexers.clone().into(); + + assert_eq!(sonarr_serdeable, SonarrSerdeable::Indexers(indexers)); + } + + #[test] + fn test_sonarr_serdeable_from_indexer_settings() { + let indexer_settings = IndexerSettings { + id: 1, + ..IndexerSettings::default() + }; + + let sonarr_serdeable: SonarrSerdeable = indexer_settings.clone().into(); + + assert_eq!( + sonarr_serdeable, + SonarrSerdeable::IndexerSettings(indexer_settings) + ); + } + #[test] fn test_sonarr_serdeable_from_series() { let series = vec![Series { @@ -189,4 +230,19 @@ mod tests { SonarrSerdeable::QualityProfiles(quality_profiles) ); } + + #[test] + fn test_sonarr_serdeable_from_security_config() { + let security_config = SecurityConfig { + username: Some("Test".to_owned()), + ..SecurityConfig::default() + }; + + let sonarr_serdeable: SonarrSerdeable = security_config.clone().into(); + + assert_eq!( + sonarr_serdeable, + SonarrSerdeable::SecurityConfig(security_config) + ); + } } diff --git a/src/network/radarr_network.rs b/src/network/radarr_network.rs index 99490ad..9a48bb8 100644 --- a/src/network/radarr_network.rs +++ b/src/network/radarr_network.rs @@ -187,7 +187,7 @@ impl<'a, 'b> Network<'a, 'b> { } RadarrEvent::EditMovie(params) => self.edit_movie(params).await.map(RadarrSerdeable::from), RadarrEvent::GetAllIndexerSettings => self - .get_all_indexer_settings() + .get_all_radarr_indexer_settings() .await .map(RadarrSerdeable::from), RadarrEvent::GetBlocklist => self.get_radarr_blocklist().await.map(RadarrSerdeable::from), @@ -1417,7 +1417,7 @@ impl<'a, 'b> Network<'a, 'b> { .await } - async fn get_all_indexer_settings(&mut self) -> Result { + async fn get_all_radarr_indexer_settings(&mut self) -> Result { info!("Fetching Radarr indexer settings"); let event = RadarrEvent::GetAllIndexerSettings; diff --git a/src/network/sonarr_network.rs b/src/network/sonarr_network.rs index 25aedef..0ba51a4 100644 --- a/src/network/sonarr_network.rs +++ b/src/network/sonarr_network.rs @@ -2,7 +2,7 @@ use std::collections::BTreeMap; use anyhow::Result; use indoc::formatdoc; -use log::info; +use log::{debug, info}; use managarr_tree_widget::TreeItem; use serde_json::{json, Value}; @@ -11,8 +11,8 @@ use crate::{ servarr_data::sonarr::{modals::EpisodeDetailsModal, sonarr_data::ActiveSonarrBlock}, servarr_models::{HostConfig, Indexer, SecurityConfig}, sonarr_models::{ - BlocklistResponse, DownloadRecord, DownloadsResponse, Episode, LogResponse, QualityProfile, - Series, SonarrSerdeable, SystemStatus, + BlocklistResponse, DownloadRecord, DownloadsResponse, Episode, IndexerSettings, LogResponse, + QualityProfile, Series, SonarrSerdeable, SystemStatus, }, HorizontallyScrollableText, Route, Scrollable, ScrollableText, }, @@ -29,6 +29,7 @@ mod sonarr_network_tests; pub enum SonarrEvent { ClearBlocklist, DeleteBlocklistItem(Option), + GetAllIndexerSettings, GetBlocklist, GetDownloads, GetHostConfig, @@ -48,6 +49,7 @@ impl NetworkResource for SonarrEvent { match &self { SonarrEvent::ClearBlocklist => "/blocklist/bulk", SonarrEvent::DeleteBlocklistItem(_) => "/blocklist", + SonarrEvent::GetAllIndexerSettings => "/config/indexer", SonarrEvent::GetBlocklist => "/blocklist?page=1&pageSize=10000", SonarrEvent::GetDownloads => "/queue", SonarrEvent::GetEpisodes(_) | SonarrEvent::GetEpisodeDetails(_) => "/episode", @@ -78,6 +80,10 @@ impl<'a, 'b> Network<'a, 'b> { .clear_sonarr_blocklist() .await .map(SonarrSerdeable::from), + SonarrEvent::GetAllIndexerSettings => self + .get_all_sonarr_indexer_settings() + .await + .map(SonarrSerdeable::from), SonarrEvent::DeleteBlocklistItem(blocklist_item_id) => self .delete_sonarr_blocklist_item(blocklist_item_id) .await @@ -182,6 +188,25 @@ impl<'a, 'b> Network<'a, 'b> { .await } + async fn get_all_sonarr_indexer_settings(&mut self) -> Result { + info!("Fetching Sonarr indexer settings"); + let event = SonarrEvent::GetAllIndexerSettings; + + let request_props = self + .request_props_from(event, RequestMethod::Get, None::<()>, None, None) + .await; + + self + .handle_request::<(), IndexerSettings>(request_props, |indexer_settings, mut app| { + if app.data.sonarr_data.indexer_settings.is_none() { + app.data.sonarr_data.indexer_settings = Some(indexer_settings); + } else { + debug!("Indexer Settings are being modified. Ignoring update..."); + } + }) + .await + } + async fn get_sonarr_healthcheck(&mut self) -> Result<()> { info!("Performing Sonarr health check"); let event = SonarrEvent::HealthCheck; From 9ceb55a314c598b250e43b44a8e7ef050c0c3a5a Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Mon, 18 Nov 2024 21:25:50 -0700 Subject: [PATCH 018/119] feat(cli): Added CLI support for fetching all indexer settings for Sonarr --- src/cli/sonarr/get_command_handler.rs | 5 +++ src/cli/sonarr/get_command_handler_tests.rs | 36 +++++++++++++++++++++ src/models/sonarr_models.rs | 2 +- 3 files changed, 42 insertions(+), 1 deletion(-) diff --git a/src/cli/sonarr/get_command_handler.rs b/src/cli/sonarr/get_command_handler.rs index c0356ce..8d29cbc 100644 --- a/src/cli/sonarr/get_command_handler.rs +++ b/src/cli/sonarr/get_command_handler.rs @@ -19,6 +19,8 @@ mod get_command_handler_tests; #[derive(Debug, Clone, PartialEq, Eq, Subcommand)] pub enum SonarrGetCommand { + #[command(about = "Get the shared settings for all indexers")] + AllIndexerSettings, #[command(about = "Get detailed information for the episode with the given ID")] EpisodeDetails { #[arg( @@ -63,6 +65,9 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, SonarrGetCommand> for SonarrGetCommandHan async fn handle(self) -> Result<()> { match self.command { + SonarrGetCommand::AllIndexerSettings => { + execute_network_event!(self, SonarrEvent::GetAllIndexerSettings); + } SonarrGetCommand::EpisodeDetails { episode_id } => { execute_network_event!(self, SonarrEvent::GetEpisodeDetails(Some(episode_id))); } diff --git a/src/cli/sonarr/get_command_handler_tests.rs b/src/cli/sonarr/get_command_handler_tests.rs index 00e7a38..8905048 100644 --- a/src/cli/sonarr/get_command_handler_tests.rs +++ b/src/cli/sonarr/get_command_handler_tests.rs @@ -21,6 +21,14 @@ mod tests { use super::*; + #[test] + fn test_all_indexer_settings_has_no_arg_requirements() { + let result = + Cli::command().try_get_matches_from(["managarr", "sonarr", "get", "all-indexer-settings"]); + + assert!(result.is_ok()); + } + #[test] fn test_system_status_has_no_arg_requirements() { let result = @@ -89,6 +97,34 @@ mod tests { network::{sonarr_network::SonarrEvent, MockNetworkTrait, NetworkEvent}, }; + #[tokio::test] + async fn test_handle_get_all_indexer_settings_command() { + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::( + SonarrEvent::GetAllIndexerSettings.into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Sonarr(SonarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::default())); + let get_all_indexer_settings_command = SonarrGetCommand::AllIndexerSettings; + + let result = SonarrGetCommandHandler::with( + &app_arc, + get_all_indexer_settings_command, + &mut mock_network, + ) + .handle() + .await; + + assert!(result.is_ok()); + } + #[tokio::test] async fn test_handle_get_episode_details_command() { let expected_episode_id = 1; diff --git a/src/models/sonarr_models.rs b/src/models/sonarr_models.rs index cdec965..7e3505d 100644 --- a/src/models/sonarr_models.rs +++ b/src/models/sonarr_models.rs @@ -112,7 +112,7 @@ pub struct IndexerSettings { #[serde(deserialize_with = "super::from_i64")] pub id: i64, #[serde(deserialize_with = "super::from_i64")] - pub mimimum_age: i64, + pub minimum_age: i64, #[serde(deserialize_with = "super::from_i64")] pub retention: i64, #[serde(deserialize_with = "super::from_i64")] From 6b64b5ecc4852195deb1229440301465e67577f1 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Tue, 19 Nov 2024 12:01:07 -0700 Subject: [PATCH 019/119] feat(network): Added support for fetching Sonarr queued events --- .../system/system_details_handler_tests.rs | 3 +- .../system/system_handler_tests.rs | 3 +- src/models/radarr_models.rs | 15 +---- src/models/radarr_models_tests.rs | 4 +- src/models/servarr_data/radarr/radarr_data.rs | 4 +- src/models/servarr_data/sonarr/sonarr_data.rs | 32 +++++----- .../servarr_data/sonarr/sonarr_data_tests.rs | 15 ++--- src/models/servarr_models.rs | 14 +++++ src/models/sonarr_models.rs | 4 +- src/models/sonarr_models_tests.rs | 14 ++++- src/network/radarr_network.rs | 13 ++-- src/network/radarr_network_tests.rs | 2 +- src/network/sonarr_network.rs | 27 ++++++++- src/network/sonarr_network_tests.rs | 60 ++++++++++++++++++- src/ui/radarr_ui/system/mod.rs | 3 +- 15 files changed, 160 insertions(+), 53 deletions(-) diff --git a/src/handlers/radarr_handlers/system/system_details_handler_tests.rs b/src/handlers/radarr_handlers/system/system_details_handler_tests.rs index c8947a8..cde9d2f 100644 --- a/src/handlers/radarr_handlers/system/system_details_handler_tests.rs +++ b/src/handlers/radarr_handlers/system/system_details_handler_tests.rs @@ -8,10 +8,11 @@ mod tests { use crate::event::Key; use crate::handlers::radarr_handlers::system::system_details_handler::SystemDetailsHandler; use crate::handlers::KeyEventHandler; - use crate::models::radarr_models::{QueueEvent, Task}; + use crate::models::radarr_models::Task; use crate::models::servarr_data::radarr::radarr_data::{ ActiveRadarrBlock, SYSTEM_DETAILS_BLOCKS, }; + use crate::models::servarr_models::QueueEvent; use crate::models::{HorizontallyScrollableText, ScrollableText}; mod test_handle_scroll_up_and_down { diff --git a/src/handlers/radarr_handlers/system/system_handler_tests.rs b/src/handlers/radarr_handlers/system/system_handler_tests.rs index 5f2234d..b1c864a 100644 --- a/src/handlers/radarr_handlers/system/system_handler_tests.rs +++ b/src/handlers/radarr_handlers/system/system_handler_tests.rs @@ -9,10 +9,11 @@ mod tests { use crate::event::Key; use crate::handlers::radarr_handlers::system::SystemHandler; use crate::handlers::KeyEventHandler; - use crate::models::radarr_models::{QueueEvent, Task}; + use crate::models::radarr_models::Task; use crate::models::servarr_data::radarr::radarr_data::{ ActiveRadarrBlock, SYSTEM_DETAILS_BLOCKS, }; + use crate::models::servarr_models::QueueEvent; use crate::test_handler_delegation; mod test_handle_left_right_action { diff --git a/src/models/radarr_models.rs b/src/models/radarr_models.rs index 5934cfc..83aef7c 100644 --- a/src/models/radarr_models.rs +++ b/src/models/radarr_models.rs @@ -9,7 +9,7 @@ use strum_macros::EnumIter; use crate::{models::HorizontallyScrollableText, serde_enum_from}; -use super::servarr_models::{HostConfig, Indexer, SecurityConfig}; +use super::servarr_models::{HostConfig, Indexer, QueueEvent, SecurityConfig}; use super::Serdeable; #[cfg(test)] @@ -462,19 +462,6 @@ pub struct QualityWrapper { pub quality: Quality, } -#[derive(Default, Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct QueueEvent { - pub trigger: String, - pub name: String, - pub command_name: String, - pub status: String, - pub queued: DateTime, - pub started: Option>, - pub ended: Option>, - pub duration: Option, -} - #[derive(Derivative, Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] #[derivative(Default)] pub struct Rating { diff --git a/src/models/radarr_models_tests.rs b/src/models/radarr_models_tests.rs index 90ce5e2..da0c5df 100644 --- a/src/models/radarr_models_tests.rs +++ b/src/models/radarr_models_tests.rs @@ -8,9 +8,9 @@ mod tests { AddMovieSearchResult, BlocklistItem, BlocklistResponse, Collection, Credit, DiskSpace, DownloadRecord, DownloadsResponse, Indexer, IndexerSettings, IndexerTestResult, Log, LogResponse, MinimumAvailability, Monitor, Movie, MovieHistoryItem, QualityProfile, - QueueEvent, RadarrSerdeable, Release, RootFolder, SystemStatus, Tag, Task, TaskName, Update, + RadarrSerdeable, Release, RootFolder, SystemStatus, Tag, Task, TaskName, Update, }, - servarr_models::{HostConfig, SecurityConfig}, + servarr_models::{HostConfig, QueueEvent, SecurityConfig}, Serdeable, }; diff --git a/src/models/servarr_data/radarr/radarr_data.rs b/src/models/servarr_data/radarr/radarr_data.rs index e6ca06f..e3d4a7c 100644 --- a/src/models/servarr_data/radarr/radarr_data.rs +++ b/src/models/servarr_data/radarr/radarr_data.rs @@ -7,13 +7,13 @@ use crate::app::radarr::radarr_context_clues::{ }; use crate::models::radarr_models::{ AddMovieSearchResult, BlocklistItem, Collection, CollectionMovie, DiskSpace, DownloadRecord, - IndexerSettings, Movie, QueueEvent, RootFolder, Task, + IndexerSettings, Movie, RootFolder, Task, }; use crate::models::servarr_data::radarr::modals::{ AddMovieModal, EditCollectionModal, EditIndexerModal, EditMovieModal, IndexerTestResultModalItem, MovieDetailsModal, }; -use crate::models::servarr_models::Indexer; +use crate::models::servarr_models::{Indexer, QueueEvent}; use crate::models::stateful_list::StatefulList; use crate::models::stateful_table::StatefulTable; use crate::models::{ diff --git a/src/models/servarr_data/sonarr/sonarr_data.rs b/src/models/servarr_data/sonarr/sonarr_data.rs index b2eeee9..427ea0f 100644 --- a/src/models/servarr_data/sonarr/sonarr_data.rs +++ b/src/models/servarr_data/sonarr/sonarr_data.rs @@ -3,7 +3,7 @@ use chrono::{DateTime, Utc}; use strum::EnumIter; use crate::models::{ - servarr_models::Indexer, + servarr_models::{Indexer, QueueEvent}, sonarr_models::{BlocklistItem, DownloadRecord, Episode, IndexerSettings, Series}, stateful_list::StatefulList, stateful_table::StatefulTable, @@ -18,35 +18,37 @@ use super::modals::EpisodeDetailsModal; mod sonarr_data_tests; pub struct SonarrData { - pub version: String, - pub start_time: DateTime, - pub series: StatefulTable, pub blocklist: StatefulTable, - pub logs: StatefulList, - pub episodes_tree: StatefulTree, - pub episodes_table: StatefulTable, pub downloads: StatefulTable, pub episode_details_modal: Option, - pub quality_profile_map: BiMap, + pub episodes_table: StatefulTable, + pub episodes_tree: StatefulTree, pub indexers: StatefulTable, pub indexer_settings: Option, + pub logs: StatefulList, + pub quality_profile_map: BiMap, + pub queued_events: StatefulTable, + pub series: StatefulTable, + pub start_time: DateTime, + pub version: String, } impl Default for SonarrData { fn default() -> SonarrData { SonarrData { - version: String::new(), - start_time: DateTime::default(), - series: StatefulTable::default(), blocklist: StatefulTable::default(), - logs: StatefulList::default(), - episodes_tree: StatefulTree::default(), - episodes_table: StatefulTable::default(), downloads: StatefulTable::default(), episode_details_modal: None, - quality_profile_map: BiMap::new(), + episodes_table: StatefulTable::default(), + episodes_tree: StatefulTree::default(), indexers: StatefulTable::default(), indexer_settings: None, + logs: StatefulList::default(), + quality_profile_map: BiMap::new(), + queued_events: StatefulTable::default(), + series: StatefulTable::default(), + start_time: DateTime::default(), + version: String::new(), } } } diff --git a/src/models/servarr_data/sonarr/sonarr_data_tests.rs b/src/models/servarr_data/sonarr/sonarr_data_tests.rs index 66ba96c..49efe6c 100644 --- a/src/models/servarr_data/sonarr/sonarr_data_tests.rs +++ b/src/models/servarr_data/sonarr/sonarr_data_tests.rs @@ -34,18 +34,19 @@ mod tests { fn test_sonarr_data_defaults() { let sonarr_data = SonarrData::default(); - 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()); - assert!(sonarr_data.logs.is_empty()); - assert!(sonarr_data.episodes_tree.is_empty()); - assert!(sonarr_data.episodes_table.is_empty()); assert!(sonarr_data.downloads.is_empty()); assert!(sonarr_data.episode_details_modal.is_none()); - assert!(sonarr_data.quality_profile_map.is_empty()); + assert!(sonarr_data.episodes_table.is_empty()); + assert!(sonarr_data.episodes_tree.is_empty()); assert!(sonarr_data.indexers.is_empty()); assert!(sonarr_data.indexer_settings.is_none()); + assert!(sonarr_data.logs.is_empty()); + assert!(sonarr_data.quality_profile_map.is_empty()); + assert!(sonarr_data.queued_events.is_empty()); + assert!(sonarr_data.series.is_empty()); + assert_eq!(sonarr_data.start_time, >::default()); + assert!(sonarr_data.version.is_empty()); } } } diff --git a/src/models/servarr_models.rs b/src/models/servarr_models.rs index 0944b74..8c04e9e 100644 --- a/src/models/servarr_models.rs +++ b/src/models/servarr_models.rs @@ -1,5 +1,6 @@ use std::fmt::{Display, Formatter, Result}; +use chrono::{DateTime, Utc}; use clap::ValueEnum; use serde::{Deserialize, Serialize}; use serde_json::{Number, Value}; @@ -113,6 +114,19 @@ pub struct IndexerField { pub value: Option, } +#[derive(Default, Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct QueueEvent { + pub trigger: String, + pub name: String, + pub command_name: String, + pub status: String, + pub queued: DateTime, + pub started: Option>, + pub ended: Option>, + pub duration: Option, +} + #[derive(Default, Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct SecurityConfig { diff --git a/src/models/sonarr_models.rs b/src/models/sonarr_models.rs index 7e3505d..a712e7f 100644 --- a/src/models/sonarr_models.rs +++ b/src/models/sonarr_models.rs @@ -10,7 +10,7 @@ use strum::EnumIter; use crate::serde_enum_from; use super::{ - servarr_models::{HostConfig, Indexer, SecurityConfig}, + servarr_models::{HostConfig, Indexer, QueueEvent, SecurityConfig}, HorizontallyScrollableText, Serdeable, }; @@ -352,6 +352,7 @@ pub enum SonarrSerdeable { IndexerSettings(IndexerSettings), Indexers(Vec), QualityProfiles(Vec), + QueueEvents(Vec), SecurityConfig(SecurityConfig), SeriesVec(Vec), SystemStatus(SystemStatus), @@ -381,6 +382,7 @@ serde_enum_from!( IndexerSettings(IndexerSettings), Indexers(Vec), QualityProfiles(Vec), + QueueEvents(Vec), SecurityConfig(SecurityConfig), SeriesVec(Vec), SystemStatus(SystemStatus), diff --git a/src/models/sonarr_models_tests.rs b/src/models/sonarr_models_tests.rs index d561b7f..4ff109a 100644 --- a/src/models/sonarr_models_tests.rs +++ b/src/models/sonarr_models_tests.rs @@ -4,7 +4,7 @@ mod tests { use serde_json::json; use crate::models::{ - servarr_models::{HostConfig, Indexer, SecurityConfig}, + servarr_models::{HostConfig, Indexer, QueueEvent, SecurityConfig}, sonarr_models::{ BlocklistItem, BlocklistResponse, DownloadRecord, DownloadsResponse, Episode, IndexerSettings, Log, LogResponse, QualityProfile, Series, SeriesStatus, SeriesType, @@ -231,6 +231,18 @@ mod tests { ); } + #[test] + fn test_sonarr_serdeable_from_queue_events() { + let queue_events = vec![QueueEvent { + trigger: "test".to_owned(), + ..QueueEvent::default() + }]; + + let sonarr_serdeable: SonarrSerdeable = queue_events.clone().into(); + + assert_eq!(sonarr_serdeable, SonarrSerdeable::QueueEvents(queue_events)); + } + #[test] fn test_sonarr_serdeable_from_security_config() { let security_config = SecurityConfig { diff --git a/src/network/radarr_network.rs b/src/network/radarr_network.rs index 9a48bb8..304895e 100644 --- a/src/network/radarr_network.rs +++ b/src/network/radarr_network.rs @@ -11,15 +11,15 @@ use crate::models::radarr_models::{ CollectionMovie, CommandBody, Credit, CreditType, DeleteMovieParams, DiskSpace, DownloadRecord, DownloadsResponse, EditCollectionParams, EditIndexerParams, EditMovieParams, IndexerSettings, IndexerTestResult, LogResponse, Movie, MovieCommandBody, MovieHistoryItem, QualityProfile, - QueueEvent, RadarrSerdeable, Release, ReleaseDownloadBody, RootFolder, SystemStatus, Tag, Task, - TaskName, Update, + RadarrSerdeable, Release, ReleaseDownloadBody, RootFolder, SystemStatus, Tag, Task, TaskName, + Update, }; use crate::models::servarr_data::radarr::modals::{ AddMovieModal, EditCollectionModal, EditIndexerModal, EditMovieModal, IndexerTestResultModalItem, MovieDetailsModal, }; use crate::models::servarr_data::radarr::radarr_data::ActiveRadarrBlock; -use crate::models::servarr_models::{HostConfig, Indexer, SecurityConfig}; +use crate::models::servarr_models::{HostConfig, Indexer, QueueEvent, SecurityConfig}; use crate::models::stateful_table::StatefulTable; use crate::models::{HorizontallyScrollableText, Route, Scrollable, ScrollableText}; use crate::network::{Network, NetworkEvent, RequestMethod}; @@ -219,7 +219,10 @@ impl<'a, 'b> Network<'a, 'b> { .get_radarr_quality_profiles() .await .map(RadarrSerdeable::from), - RadarrEvent::GetQueuedEvents => self.get_queued_events().await.map(RadarrSerdeable::from), + RadarrEvent::GetQueuedEvents => self + .get_queued_radarr_events() + .await + .map(RadarrSerdeable::from), RadarrEvent::GetReleases(movie_id) => { self.get_releases(movie_id).await.map(RadarrSerdeable::from) } @@ -1728,7 +1731,7 @@ impl<'a, 'b> Network<'a, 'b> { .await } - async fn get_queued_events(&mut self) -> Result> { + async fn get_queued_radarr_events(&mut self) -> Result> { info!("Fetching Radarr queued events"); let event = RadarrEvent::GetQueuedEvents; diff --git a/src/network/radarr_network_tests.rs b/src/network/radarr_network_tests.rs index d4e5344..c80f719 100644 --- a/src/network/radarr_network_tests.rs +++ b/src/network/radarr_network_tests.rs @@ -2399,7 +2399,7 @@ mod test { } #[tokio::test] - async fn test_handle_get_queued_events_event() { + async fn test_handle_get_queued_radarr_events_event() { let queued_events_json = json!([{ "name": "RefreshMonitoredDownloads", "commandName": "Refresh Monitored Downloads", diff --git a/src/network/sonarr_network.rs b/src/network/sonarr_network.rs index 0ba51a4..2b1676d 100644 --- a/src/network/sonarr_network.rs +++ b/src/network/sonarr_network.rs @@ -9,7 +9,7 @@ use serde_json::{json, Value}; use crate::{ models::{ servarr_data::sonarr::{modals::EpisodeDetailsModal, sonarr_data::ActiveSonarrBlock}, - servarr_models::{HostConfig, Indexer, SecurityConfig}, + servarr_models::{HostConfig, Indexer, QueueEvent, SecurityConfig}, sonarr_models::{ BlocklistResponse, DownloadRecord, DownloadsResponse, Episode, IndexerSettings, LogResponse, QualityProfile, Series, SonarrSerdeable, SystemStatus, @@ -38,6 +38,7 @@ pub enum SonarrEvent { GetEpisodes(Option), GetLogs(Option), GetQualityProfiles, + GetQueuedEvents, GetSecurityConfig, GetStatus, HealthCheck, @@ -57,6 +58,7 @@ impl NetworkResource for SonarrEvent { SonarrEvent::GetIndexers => "/indexer", SonarrEvent::GetLogs(_) => "/log", SonarrEvent::GetQualityProfiles => "/qualityprofile", + SonarrEvent::GetQueuedEvents => "/command", SonarrEvent::GetStatus => "/system/status", SonarrEvent::HealthCheck => "/health", SonarrEvent::ListSeries => "/series", @@ -107,6 +109,10 @@ impl<'a, 'b> Network<'a, 'b> { .get_sonarr_quality_profiles() .await .map(SonarrSerdeable::from), + SonarrEvent::GetQueuedEvents => self + .get_queued_sonarr_events() + .await + .map(SonarrSerdeable::from), SonarrEvent::GetLogs(events) => self .get_sonarr_logs(events) .await @@ -523,6 +529,25 @@ impl<'a, 'b> Network<'a, 'b> { .await } + async fn get_queued_sonarr_events(&mut self) -> Result> { + info!("Fetching Sonarr queued events"); + let event = SonarrEvent::GetQueuedEvents; + + let request_props = self + .request_props_from(event, RequestMethod::Get, None::<()>, None, None) + .await; + + self + .handle_request::<(), Vec>(request_props, |queued_events_vec, mut app| { + app + .data + .sonarr_data + .queued_events + .set_items(queued_events_vec); + }) + .await + } + async fn get_sonarr_security_config(&mut self) -> Result { info!("Fetching Sonarr security config"); let event = SonarrEvent::GetSecurityConfig; diff --git a/src/network/sonarr_network_tests.rs b/src/network/sonarr_network_tests.rs index 5fe5d46..b16200d 100644 --- a/src/network/sonarr_network_tests.rs +++ b/src/network/sonarr_network_tests.rs @@ -22,7 +22,9 @@ mod test { use crate::app::App; use crate::models::servarr_data::sonarr::sonarr_data::ActiveSonarrBlock; - use crate::models::servarr_models::{HostConfig, Indexer, IndexerField, SecurityConfig}; + use crate::models::servarr_models::{ + HostConfig, Indexer, IndexerField, QueueEvent, SecurityConfig, + }; use crate::models::sonarr_models::{ BlocklistItem, DownloadRecord, DownloadsResponse, Episode, EpisodeFile, Language, LogResponse, MediaInfo, QualityProfile, @@ -145,6 +147,11 @@ mod test { assert_str_eq!(event.resource(), "/config/host"); } + #[rstest] + fn test_resource_command(#[values(SonarrEvent::GetQueuedEvents)] event: SonarrEvent) { + assert_str_eq!(event.resource(), "/command"); + } + #[rstest] fn test_resource_indexer(#[values(SonarrEvent::GetIndexers)] event: SonarrEvent) { assert_str_eq!(event.resource(), "/indexer"); @@ -1130,6 +1137,57 @@ mod test { } } + #[tokio::test] + async fn test_handle_get_queued_sonarr_events_event() { + let queued_events_json = json!([{ + "name": "RefreshMonitoredDownloads", + "commandName": "Refresh Monitored Downloads", + "status": "completed", + "queued": "2023-05-20T21:29:16Z", + "started": "2023-05-20T21:29:16Z", + "ended": "2023-05-20T21:29:16Z", + "duration": "00:00:00.5111547", + "trigger": "scheduled", + }]); + let response: Vec = serde_json::from_value(queued_events_json.clone()).unwrap(); + let timestamp = DateTime::from(DateTime::parse_from_rfc3339("2023-05-20T21:29:16Z").unwrap()); + let expected_event = QueueEvent { + name: "RefreshMonitoredDownloads".to_owned(), + command_name: "Refresh Monitored Downloads".to_owned(), + status: "completed".to_owned(), + queued: timestamp, + started: Some(timestamp), + ended: Some(timestamp), + duration: Some("00:00:00.5111547".to_owned()), + trigger: "scheduled".to_owned(), + }; + + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(queued_events_json), + None, + SonarrEvent::GetQueuedEvents, + None, + None, + ) + .await; + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + if let SonarrSerdeable::QueueEvents(events) = network + .handle_sonarr_event(SonarrEvent::GetQueuedEvents) + .await + .unwrap() + { + async_server.assert_async().await; + assert_eq!( + app_arc.lock().await.data.sonarr_data.queued_events.items, + vec![expected_event] + ); + assert_eq!(events, response); + } + } + #[rstest] #[tokio::test] async fn test_handle_get_series_event(#[values(true, false)] use_custom_sorting: bool) { diff --git a/src/ui/radarr_ui/system/mod.rs b/src/ui/radarr_ui/system/mod.rs index 93c1a04..5616649 100644 --- a/src/ui/radarr_ui/system/mod.rs +++ b/src/ui/radarr_ui/system/mod.rs @@ -12,8 +12,9 @@ use ratatui::{ }; use crate::app::App; -use crate::models::radarr_models::{QueueEvent, Task}; +use crate::models::radarr_models::Task; use crate::models::servarr_data::radarr::radarr_data::ActiveRadarrBlock; +use crate::models::servarr_models::QueueEvent; use crate::ui::radarr_ui::radarr_ui_utils::{convert_to_minutes_hours_days, style_log_list_item}; use crate::ui::radarr_ui::system::system_details_ui::SystemDetailsUi; use crate::ui::styles::ManagarrStyle; From 2876913f48a81f2477b8dcc03df50cd6862cfc7a Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Tue, 19 Nov 2024 12:03:19 -0700 Subject: [PATCH 020/119] feat(cli): Added support for listing Sonarr queued events --- src/cli/sonarr/list_command_handler.rs | 5 +++++ src/cli/sonarr/list_command_handler_tests.rs | 10 +++++++++- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/src/cli/sonarr/list_command_handler.rs b/src/cli/sonarr/list_command_handler.rs index 470d97e..a03c993 100644 --- a/src/cli/sonarr/list_command_handler.rs +++ b/src/cli/sonarr/list_command_handler.rs @@ -46,6 +46,8 @@ pub enum SonarrListCommand { }, #[command(about = "List all Sonarr quality profiles")] QualityProfiles, + #[command(about = "List all queued events")] + QueuedEvents, #[command(about = "List all series in your Sonarr library")] Series, } @@ -111,6 +113,9 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, SonarrListCommand> for SonarrListCommandH SonarrListCommand::QualityProfiles => { execute_network_event!(self, SonarrEvent::GetQualityProfiles); } + SonarrListCommand::QueuedEvents => { + execute_network_event!(self, SonarrEvent::GetQueuedEvents); + } 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 99adf6f..c5f48b8 100644 --- a/src/cli/sonarr/list_command_handler_tests.rs +++ b/src/cli/sonarr/list_command_handler_tests.rs @@ -24,7 +24,14 @@ mod tests { #[rstest] fn test_list_commands_have_no_arg_requirements( - #[values("blocklist", "series", "downloads", "quality-profiles", "indexers")] + #[values( + "blocklist", + "series", + "downloads", + "quality-profiles", + "indexers", + "queued-events" + )] subcommand: &str, ) { let result = Cli::command().try_get_matches_from(["managarr", "sonarr", "list", subcommand]); @@ -106,6 +113,7 @@ mod tests { #[case(SonarrListCommand::Downloads, SonarrEvent::GetDownloads)] #[case(SonarrListCommand::Indexers, SonarrEvent::GetIndexers)] #[case(SonarrListCommand::QualityProfiles, SonarrEvent::GetQualityProfiles)] + #[case(SonarrListCommand::QueuedEvents, SonarrEvent::GetQueuedEvents)] #[case(SonarrListCommand::Series, SonarrEvent::ListSeries)] #[tokio::test] async fn test_handle_list_command( From cc028325121e780112498c862546d06eed49f51a Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Tue, 19 Nov 2024 15:59:35 -0700 Subject: [PATCH 021/119] feat(network): Added support for fetching season releases for Sonarr --- src/app/app_tests.rs | 1 + src/app/mod.rs | 2 + src/app/radarr/radarr_tests.rs | 3 +- .../blocklist/blocklist_handler_tests.rs | 5 +- .../library/library_handler_tests.rs | 3 +- .../library/movie_details_handler.rs | 2 +- .../library/movie_details_handler_tests.rs | 5 +- src/main.rs | 1 + src/models/radarr_models.rs | 75 +- src/models/radarr_models_tests.rs | 8 +- src/models/servarr_data/radarr/modals.rs | 4 +- .../servarr_data/radarr/radarr_test_utils.rs | 3 +- src/models/servarr_data/sonarr/modals.rs | 14 +- src/models/servarr_data/sonarr/sonarr_data.rs | 22 +- .../servarr_data/sonarr/sonarr_data_tests.rs | 5 +- src/models/servarr_models.rs | 70 ++ src/models/servarr_models_tests.rs | 19 +- src/models/sonarr_models.rs | 46 +- src/models/sonarr_models_tests.rs | 19 +- src/network/radarr_network.rs | 18 +- src/network/radarr_network_tests.rs | 8 +- src/network/sonarr_network.rs | 163 ++-- src/network/sonarr_network_tests.rs | 733 +++++++++++++----- src/ui/radarr_ui/library/movie_details_ui.rs | 3 +- 24 files changed, 830 insertions(+), 402 deletions(-) diff --git a/src/app/app_tests.rs b/src/app/app_tests.rs index 102a064..6b98a5e 100644 --- a/src/app/app_tests.rs +++ b/src/app/app_tests.rs @@ -48,6 +48,7 @@ mod tests { assert!(!app.is_routing); assert!(!app.should_refresh); assert!(!app.should_ignore_quit_key); + assert!(!app.cli_mode); } #[test] diff --git a/src/app/mod.rs b/src/app/mod.rs index 764280a..ba95fcc 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -36,6 +36,7 @@ pub struct App<'a> { pub is_loading: bool, pub should_refresh: bool, pub should_ignore_quit_key: bool, + pub cli_mode: bool, pub config: AppConfig, pub data: Data<'a>, } @@ -164,6 +165,7 @@ impl<'a> Default for App<'a> { is_routing: false, should_refresh: false, should_ignore_quit_key: false, + cli_mode: false, config: AppConfig::default(), data: Data::default(), } diff --git a/src/app/radarr/radarr_tests.rs b/src/app/radarr/radarr_tests.rs index 286d21f..7a2ab5a 100644 --- a/src/app/radarr/radarr_tests.rs +++ b/src/app/radarr/radarr_tests.rs @@ -6,9 +6,10 @@ mod tests { use crate::app::radarr::ActiveRadarrBlock; use crate::app::App; - use crate::models::radarr_models::{Collection, CollectionMovie, Credit, Release}; + use crate::models::radarr_models::{Collection, CollectionMovie, Credit}; use crate::models::servarr_data::radarr::modals::MovieDetailsModal; + use crate::models::servarr_models::Release; use crate::network::radarr_network::RadarrEvent; use crate::network::NetworkEvent; diff --git a/src/handlers/radarr_handlers/blocklist/blocklist_handler_tests.rs b/src/handlers/radarr_handlers/blocklist/blocklist_handler_tests.rs index 2dd8551..d1b94b8 100644 --- a/src/handlers/radarr_handlers/blocklist/blocklist_handler_tests.rs +++ b/src/handlers/radarr_handlers/blocklist/blocklist_handler_tests.rs @@ -11,10 +11,9 @@ mod tests { use crate::event::Key; use crate::handlers::radarr_handlers::blocklist::{blocklist_sorting_options, BlocklistHandler}; use crate::handlers::KeyEventHandler; - use crate::models::radarr_models::{ - BlocklistItem, BlocklistItemMovie, Language, Quality, QualityWrapper, - }; + use crate::models::radarr_models::{BlocklistItem, BlocklistItemMovie}; use crate::models::servarr_data::radarr::radarr_data::{ActiveRadarrBlock, BLOCKLIST_BLOCKS}; + use crate::models::servarr_models::{Language, Quality, QualityWrapper}; use crate::models::stateful_table::SortOption; mod test_handle_scroll_up_and_down { diff --git a/src/handlers/radarr_handlers/library/library_handler_tests.rs b/src/handlers/radarr_handlers/library/library_handler_tests.rs index c5711b9..f50c804 100644 --- a/src/handlers/radarr_handlers/library/library_handler_tests.rs +++ b/src/handlers/radarr_handlers/library/library_handler_tests.rs @@ -11,11 +11,12 @@ mod tests { use crate::event::Key; use crate::handlers::radarr_handlers::library::{movies_sorting_options, LibraryHandler}; use crate::handlers::KeyEventHandler; - use crate::models::radarr_models::{Language, Movie}; + use crate::models::radarr_models::Movie; use crate::models::servarr_data::radarr::radarr_data::{ ActiveRadarrBlock, ADD_MOVIE_BLOCKS, DELETE_MOVIE_BLOCKS, EDIT_MOVIE_BLOCKS, LIBRARY_BLOCKS, MOVIE_DETAILS_BLOCKS, }; + use crate::models::servarr_models::Language; use crate::models::stateful_table::SortOption; use crate::models::HorizontallyScrollableText; use crate::test_handler_delegation; diff --git a/src/handlers/radarr_handlers/library/movie_details_handler.rs b/src/handlers/radarr_handlers/library/movie_details_handler.rs index 35f42dc..fce5b46 100644 --- a/src/handlers/radarr_handlers/library/movie_details_handler.rs +++ b/src/handlers/radarr_handlers/library/movie_details_handler.rs @@ -4,10 +4,10 @@ use crate::app::key_binding::DEFAULT_KEYBINDINGS; use crate::app::App; use crate::event::Key; use crate::handlers::{handle_prompt_toggle, KeyEventHandler}; -use crate::models::radarr_models::{Language, Release}; use crate::models::servarr_data::radarr::radarr_data::{ ActiveRadarrBlock, EDIT_MOVIE_SELECTION_BLOCKS, MOVIE_DETAILS_BLOCKS, }; +use crate::models::servarr_models::{Language, Release}; use crate::models::stateful_table::SortOption; use crate::models::{BlockSelectionState, Scrollable}; use crate::network::radarr_network::RadarrEvent; diff --git a/src/handlers/radarr_handlers/library/movie_details_handler_tests.rs b/src/handlers/radarr_handlers/library/movie_details_handler_tests.rs index e9c45b4..174232b 100644 --- a/src/handlers/radarr_handlers/library/movie_details_handler_tests.rs +++ b/src/handlers/radarr_handlers/library/movie_details_handler_tests.rs @@ -14,11 +14,10 @@ mod tests { releases_sorting_options, MovieDetailsHandler, }; use crate::handlers::KeyEventHandler; - use crate::models::radarr_models::{ - Credit, Language, MovieHistoryItem, Quality, QualityWrapper, Release, - }; + use crate::models::radarr_models::{Credit, MovieHistoryItem}; use crate::models::servarr_data::radarr::modals::MovieDetailsModal; use crate::models::servarr_data::radarr::radarr_data::{ActiveRadarrBlock, MOVIE_DETAILS_BLOCKS}; + use crate::models::servarr_models::{Language, Quality, QualityWrapper, Release}; use crate::models::stateful_table::SortOption; use crate::models::{HorizontallyScrollableText, ScrollableText}; diff --git a/src/main.rs b/src/main.rs index 3db416f..b45d3e8 100644 --- a/src/main.rs +++ b/src/main.rs @@ -113,6 +113,7 @@ async fn main() -> Result<()> { match args.command { Some(command) => match command { Command::Radarr(_) | Command::Sonarr(_) => { + app.lock().await.cli_mode = true; let app_nw = Arc::clone(&app); let mut network = Network::new(&app_nw, cancellation_token, reqwest_client); diff --git a/src/models/radarr_models.rs b/src/models/radarr_models.rs index 83aef7c..89f45b8 100644 --- a/src/models/radarr_models.rs +++ b/src/models/radarr_models.rs @@ -9,7 +9,10 @@ use strum_macros::EnumIter; use crate::{models::HorizontallyScrollableText, serde_enum_from}; -use super::servarr_models::{HostConfig, Indexer, QueueEvent, SecurityConfig}; +use super::servarr_models::{ + HostConfig, Indexer, Language, LogResponse, QualityProfile, QualityWrapper, QueueEvent, Release, + SecurityConfig, +}; use super::Serdeable; #[cfg(test)] @@ -262,28 +265,6 @@ pub struct IndexerValidationFailure { pub severity: String, } -#[derive(Serialize, Deserialize, Default, Debug, Clone, PartialEq, Eq, Ord, PartialOrd)] -pub struct Language { - pub name: String, -} - -#[derive(Default, Serialize, Deserialize, Clone, Debug, Eq, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct Log { - pub time: DateTime, - pub exception: Option, - pub exception_type: Option, - pub level: String, - pub logger: Option, - pub message: Option, - pub method: Option, -} - -#[derive(Default, Clone, Serialize, Deserialize, Debug, Eq, PartialEq)] -pub struct LogResponse { - pub records: Vec, -} - #[derive(Serialize, Deserialize, Derivative, Debug, Clone, PartialEq, Eq)] #[derivative(Default)] #[serde(rename_all = "camelCase")] @@ -436,32 +417,6 @@ pub struct MovieHistoryItem { pub event_type: String, } -#[derive(Serialize, Deserialize, Default, Debug, Clone, PartialEq, Eq, Ord, PartialOrd)] -pub struct Quality { - pub name: String, -} - -#[derive(Default, Clone, Serialize, Deserialize, Debug, PartialEq, Eq)] -pub struct QualityProfile { - #[serde(deserialize_with = "super::from_i64")] - pub id: i64, - pub name: String, -} - -impl From<(&i64, &String)> for QualityProfile { - fn from(value: (&i64, &String)) -> Self { - QualityProfile { - id: *value.0, - name: value.1.clone(), - } - } -} - -#[derive(Serialize, Deserialize, Default, Debug, Clone, PartialEq, Eq, Ord, PartialOrd)] -pub struct QualityWrapper { - pub quality: Quality, -} - #[derive(Derivative, Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] #[derivative(Default)] pub struct Rating { @@ -477,28 +432,6 @@ pub struct RatingsList { pub rotten_tomatoes: Option, } -#[derive(Serialize, Deserialize, Default, Clone, Debug, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -#[serde(default)] -pub struct Release { - pub guid: String, - pub protocol: String, - #[serde(deserialize_with = "super::from_i64")] - pub age: i64, - pub title: HorizontallyScrollableText, - pub indexer: String, - #[serde(deserialize_with = "super::from_i64")] - pub indexer_id: i64, - #[serde(deserialize_with = "super::from_i64")] - pub size: i64, - pub rejected: bool, - pub rejections: Option>, - pub seeders: Option, - pub leechers: Option, - pub languages: Option>, - pub quality: QualityWrapper, -} - #[derive(Default, Serialize, Debug, PartialEq, Eq, Clone)] #[serde(rename_all = "camelCase")] pub struct ReleaseDownloadBody { diff --git a/src/models/radarr_models_tests.rs b/src/models/radarr_models_tests.rs index da0c5df..3ef03b7 100644 --- a/src/models/radarr_models_tests.rs +++ b/src/models/radarr_models_tests.rs @@ -6,11 +6,11 @@ mod tests { use crate::models::{ radarr_models::{ AddMovieSearchResult, BlocklistItem, BlocklistResponse, Collection, Credit, DiskSpace, - DownloadRecord, DownloadsResponse, Indexer, IndexerSettings, IndexerTestResult, Log, - LogResponse, MinimumAvailability, Monitor, Movie, MovieHistoryItem, QualityProfile, - RadarrSerdeable, Release, RootFolder, SystemStatus, Tag, Task, TaskName, Update, + DownloadRecord, DownloadsResponse, Indexer, IndexerSettings, IndexerTestResult, + MinimumAvailability, Monitor, Movie, MovieHistoryItem, QualityProfile, RadarrSerdeable, + Release, RootFolder, SystemStatus, Tag, Task, TaskName, Update, }, - servarr_models::{HostConfig, QueueEvent, SecurityConfig}, + servarr_models::{HostConfig, Log, LogResponse, QueueEvent, SecurityConfig}, Serdeable, }; diff --git a/src/models/servarr_data/radarr/modals.rs b/src/models/servarr_data/radarr/modals.rs index 93ef321..8cfce95 100644 --- a/src/models/servarr_data/radarr/modals.rs +++ b/src/models/servarr_data/radarr/modals.rs @@ -1,10 +1,10 @@ use strum::IntoEnumIterator; use crate::models::radarr_models::{ - Collection, Credit, MinimumAvailability, Monitor, Movie, MovieHistoryItem, Release, RootFolder, + Collection, Credit, MinimumAvailability, Monitor, Movie, MovieHistoryItem, RootFolder, }; use crate::models::servarr_data::radarr::radarr_data::RadarrData; -use crate::models::servarr_models::Indexer; +use crate::models::servarr_models::{Indexer, Release}; use crate::models::stateful_list::StatefulList; use crate::models::stateful_table::StatefulTable; use crate::models::{HorizontallyScrollableText, ScrollableText}; diff --git a/src/models/servarr_data/radarr/radarr_test_utils.rs b/src/models/servarr_data/radarr/radarr_test_utils.rs index e20e530..b437060 100644 --- a/src/models/servarr_data/radarr/radarr_test_utils.rs +++ b/src/models/servarr_data/radarr/radarr_test_utils.rs @@ -1,10 +1,11 @@ #[cfg(test)] pub mod utils { use crate::models::radarr_models::{ - AddMovieSearchResult, CollectionMovie, Credit, MovieHistoryItem, Release, + AddMovieSearchResult, CollectionMovie, Credit, MovieHistoryItem, }; use crate::models::servarr_data::radarr::modals::MovieDetailsModal; use crate::models::servarr_data::radarr::radarr_data::RadarrData; + use crate::models::servarr_models::Release; use crate::models::stateful_table::StatefulTable; use crate::models::{HorizontallyScrollableText, ScrollableText}; diff --git a/src/models/servarr_data/sonarr/modals.rs b/src/models/servarr_data/sonarr/modals.rs index 0f00318..7d27bda 100644 --- a/src/models/servarr_data/sonarr/modals.rs +++ b/src/models/servarr_data/sonarr/modals.rs @@ -1,4 +1,6 @@ -use crate::models::ScrollableText; +use crate::models::{ + servarr_models::Release, sonarr_models::Episode, stateful_table::StatefulTable, ScrollableText, +}; #[derive(Default)] pub struct EpisodeDetailsModal { @@ -9,5 +11,13 @@ pub struct EpisodeDetailsModal { // pub episode_history: StatefulTable, // pub episode_cast: StatefulTable, // pub episode_crew: StatefulTable, - // pub episode_releases: StatefulTable, + pub episode_releases: StatefulTable, +} + +#[derive(Default)] +pub struct SeasonDetailsModal { + pub season_details: ScrollableText, + pub episodes: StatefulTable, + pub episode_details_modal: Option, + pub season_releases: StatefulTable, } diff --git a/src/models/servarr_data/sonarr/sonarr_data.rs b/src/models/servarr_data/sonarr/sonarr_data.rs index 427ea0f..88fb209 100644 --- a/src/models/servarr_data/sonarr/sonarr_data.rs +++ b/src/models/servarr_data/sonarr/sonarr_data.rs @@ -4,14 +4,13 @@ use strum::EnumIter; use crate::models::{ servarr_models::{Indexer, QueueEvent}, - sonarr_models::{BlocklistItem, DownloadRecord, Episode, IndexerSettings, Series}, + sonarr_models::{BlocklistItem, DownloadRecord, IndexerSettings, Season, Series}, stateful_list::StatefulList, stateful_table::StatefulTable, - stateful_tree::StatefulTree, HorizontallyScrollableText, Route, }; -use super::modals::EpisodeDetailsModal; +use super::modals::SeasonDetailsModal; #[cfg(test)] #[path = "sonarr_data_tests.rs"] @@ -20,14 +19,13 @@ mod sonarr_data_tests; pub struct SonarrData { pub blocklist: StatefulTable, pub downloads: StatefulTable, - pub episode_details_modal: Option, - pub episodes_table: StatefulTable, - pub episodes_tree: StatefulTree, pub indexers: StatefulTable, pub indexer_settings: Option, pub logs: StatefulList, pub quality_profile_map: BiMap, pub queued_events: StatefulTable, + pub seasons: StatefulTable, + pub season_details_modal: Option, pub series: StatefulTable, pub start_time: DateTime, pub version: String, @@ -38,15 +36,14 @@ impl Default for SonarrData { SonarrData { blocklist: StatefulTable::default(), downloads: StatefulTable::default(), - episode_details_modal: None, - episodes_table: StatefulTable::default(), - episodes_tree: StatefulTree::default(), indexers: StatefulTable::default(), indexer_settings: None, logs: StatefulList::default(), quality_profile_map: BiMap::new(), queued_events: StatefulTable::default(), + seasons: StatefulTable::default(), series: StatefulTable::default(), + season_details_modal: None, start_time: DateTime::default(), version: String::new(), } @@ -57,9 +54,10 @@ impl Default for SonarrData { pub enum ActiveSonarrBlock { Blocklist, BlocklistSortPrompt, - EpisodesExplorer, - EpisodesTable, - EpisodesTableSortPrompt, + Episodes, + EpisodesSortPrompt, + Seasons, + SeasonsSortPrompt, #[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 49efe6c..c4dd645 100644 --- a/src/models/servarr_data/sonarr/sonarr_data_tests.rs +++ b/src/models/servarr_data/sonarr/sonarr_data_tests.rs @@ -36,14 +36,13 @@ mod tests { assert!(sonarr_data.blocklist.is_empty()); assert!(sonarr_data.downloads.is_empty()); - assert!(sonarr_data.episode_details_modal.is_none()); - assert!(sonarr_data.episodes_table.is_empty()); - assert!(sonarr_data.episodes_tree.is_empty()); assert!(sonarr_data.indexers.is_empty()); assert!(sonarr_data.indexer_settings.is_none()); assert!(sonarr_data.logs.is_empty()); assert!(sonarr_data.quality_profile_map.is_empty()); assert!(sonarr_data.queued_events.is_empty()); + assert!(sonarr_data.seasons.is_empty()); + assert!(sonarr_data.season_details_modal.is_none()); assert!(sonarr_data.series.is_empty()); assert_eq!(sonarr_data.start_time, >::default()); assert!(sonarr_data.version.is_empty()); diff --git a/src/models/servarr_models.rs b/src/models/servarr_models.rs index 8c04e9e..909199f 100644 --- a/src/models/servarr_models.rs +++ b/src/models/servarr_models.rs @@ -114,6 +114,54 @@ pub struct IndexerField { pub value: Option, } +#[derive(Serialize, Deserialize, Default, Hash, Debug, Clone, PartialEq, Eq, Ord, PartialOrd)] +pub struct Language { + pub name: String, +} + +#[derive(Default, Serialize, Deserialize, Clone, Debug, Eq, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct Log { + pub time: DateTime, + pub exception: Option, + pub exception_type: Option, + pub level: String, + pub logger: Option, + pub message: Option, + pub method: Option, +} + +#[derive(Default, Clone, Serialize, Deserialize, Debug, Eq, PartialEq)] +pub struct LogResponse { + pub records: Vec, +} + +#[derive(Serialize, Deserialize, Default, Debug, Clone, PartialEq, Eq, Ord, PartialOrd)] +pub struct Quality { + pub name: String, +} + +#[derive(Default, Clone, Serialize, Deserialize, Debug, PartialEq, Eq)] +pub struct QualityProfile { + #[serde(deserialize_with = "super::from_i64")] + pub id: i64, + pub name: String, +} + +impl From<(&i64, &String)> for QualityProfile { + fn from(value: (&i64, &String)) -> Self { + QualityProfile { + id: *value.0, + name: value.1.clone(), + } + } +} + +#[derive(Serialize, Deserialize, Default, Debug, Clone, PartialEq, Eq, Ord, PartialOrd)] +pub struct QualityWrapper { + pub quality: Quality, +} + #[derive(Default, Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct QueueEvent { @@ -127,6 +175,28 @@ pub struct QueueEvent { pub duration: Option, } +#[derive(Serialize, Deserialize, Default, Clone, Debug, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +#[serde(default)] +pub struct Release { + pub guid: String, + pub protocol: String, + #[serde(deserialize_with = "super::from_i64")] + pub age: i64, + pub title: HorizontallyScrollableText, + pub indexer: String, + #[serde(deserialize_with = "super::from_i64")] + pub indexer_id: i64, + #[serde(deserialize_with = "super::from_i64")] + pub size: i64, + pub rejected: bool, + pub rejections: Option>, + pub seeders: Option, + pub leechers: Option, + pub languages: Option>, + pub quality: QualityWrapper, +} + #[derive(Default, Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct SecurityConfig { diff --git a/src/models/servarr_models_tests.rs b/src/models/servarr_models_tests.rs index 8b1468d..dfe4cc9 100644 --- a/src/models/servarr_models_tests.rs +++ b/src/models/servarr_models_tests.rs @@ -1,9 +1,9 @@ #[cfg(test)] mod tests { - use pretty_assertions::assert_str_eq; + use pretty_assertions::{assert_eq, assert_str_eq}; use crate::models::servarr_models::{ - AuthenticationMethod, AuthenticationRequired, CertificateValidation, + AuthenticationMethod, AuthenticationRequired, CertificateValidation, QualityProfile, }; #[test] @@ -31,4 +31,19 @@ mod tests { ); assert_str_eq!(CertificateValidation::Disabled.to_string(), "disabled"); } + + #[test] + fn test_quality_profile_from_tuple_ref() { + let id = 2; + let name = "Test".to_owned(); + let quality_profile_tuple = (&id, &name); + let expected_quality_profile = QualityProfile { + id: 2, + name: "Test".to_owned(), + }; + + let quality_profile = QualityProfile::from(quality_profile_tuple); + + assert_eq!(expected_quality_profile, quality_profile); + } } diff --git a/src/models/sonarr_models.rs b/src/models/sonarr_models.rs index a712e7f..ca74870 100644 --- a/src/models/sonarr_models.rs +++ b/src/models/sonarr_models.rs @@ -10,7 +10,10 @@ use strum::EnumIter; use crate::serde_enum_from; use super::{ - servarr_models::{HostConfig, Indexer, QueueEvent, SecurityConfig}, + servarr_models::{ + HostConfig, Indexer, Language, LogResponse, QualityProfile, QualityWrapper, QueueEvent, + Release, SecurityConfig, + }, HorizontallyScrollableText, Serdeable, }; @@ -121,28 +124,6 @@ pub struct IndexerSettings { pub rss_sync_interval: i64, } -#[derive(Serialize, Deserialize, Default, Debug, Hash, Clone, PartialEq, Eq, Ord, PartialOrd)] -pub struct Language { - pub name: String, -} - -#[derive(Default, Serialize, Deserialize, Clone, Debug, Eq, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct Log { - pub time: DateTime, - pub exception: Option, - pub exception_type: Option, - pub level: String, - pub logger: Option, - pub message: Option, - pub method: Option, -} - -#[derive(Default, Clone, Serialize, Deserialize, Debug, Eq, PartialEq)] -pub struct LogResponse { - pub records: Vec, -} - #[derive(Serialize, Deserialize, Derivative, Hash, Debug, Clone, PartialEq, Eq)] #[derivative(Default)] #[serde(rename_all = "camelCase")] @@ -168,23 +149,6 @@ pub struct MediaInfo { pub subtitles: Option, } -#[derive(Serialize, Deserialize, Default, Debug, Hash, Clone, PartialEq, Eq, Ord, PartialOrd)] -pub struct Quality { - pub name: String, -} - -#[derive(Serialize, Deserialize, Default, Debug, Hash, Clone, PartialEq, Eq, Ord, PartialOrd)] -pub struct QualityWrapper { - pub quality: Quality, -} - -#[derive(Default, Clone, Serialize, Deserialize, Debug, PartialEq, Eq)] -pub struct QualityProfile { - #[serde(deserialize_with = "super::from_i64")] - pub id: i64, - pub name: String, -} - #[derive(Derivative, Serialize, Deserialize, Debug, Clone, PartialEq)] #[derivative(Default)] pub struct Rating { @@ -353,6 +317,7 @@ pub enum SonarrSerdeable { Indexers(Vec), QualityProfiles(Vec), QueueEvents(Vec), + Releases(Vec), SecurityConfig(SecurityConfig), SeriesVec(Vec), SystemStatus(SystemStatus), @@ -383,6 +348,7 @@ serde_enum_from!( Indexers(Vec), QualityProfiles(Vec), QueueEvents(Vec), + Releases(Vec), SecurityConfig(SecurityConfig), SeriesVec(Vec), SystemStatus(SystemStatus), diff --git a/src/models/sonarr_models_tests.rs b/src/models/sonarr_models_tests.rs index 4ff109a..9d864e2 100644 --- a/src/models/sonarr_models_tests.rs +++ b/src/models/sonarr_models_tests.rs @@ -4,11 +4,12 @@ mod tests { use serde_json::json; use crate::models::{ - servarr_models::{HostConfig, Indexer, QueueEvent, SecurityConfig}, + servarr_models::{ + HostConfig, Indexer, Log, LogResponse, QualityProfile, QueueEvent, Release, SecurityConfig, + }, sonarr_models::{ BlocklistItem, BlocklistResponse, DownloadRecord, DownloadsResponse, Episode, - IndexerSettings, Log, LogResponse, QualityProfile, Series, SeriesStatus, SeriesType, - SonarrSerdeable, SystemStatus, + IndexerSettings, Series, SeriesStatus, SeriesType, SonarrSerdeable, SystemStatus, }, Serdeable, }; @@ -243,6 +244,18 @@ mod tests { assert_eq!(sonarr_serdeable, SonarrSerdeable::QueueEvents(queue_events)); } + #[test] + fn test_sonarr_serdeable_from_releases() { + let releases = vec![Release { + size: 1, + ..Release::default() + }]; + + let sonarr_serdeable: SonarrSerdeable = releases.clone().into(); + + assert_eq!(sonarr_serdeable, SonarrSerdeable::Releases(releases)); + } + #[test] fn test_sonarr_serdeable_from_security_config() { let security_config = SecurityConfig { diff --git a/src/network/radarr_network.rs b/src/network/radarr_network.rs index 304895e..aaf6d72 100644 --- a/src/network/radarr_network.rs +++ b/src/network/radarr_network.rs @@ -10,16 +10,17 @@ use crate::models::radarr_models::{ AddMovieBody, AddMovieSearchResult, AddOptions, AddRootFolderBody, BlocklistResponse, Collection, CollectionMovie, CommandBody, Credit, CreditType, DeleteMovieParams, DiskSpace, DownloadRecord, DownloadsResponse, EditCollectionParams, EditIndexerParams, EditMovieParams, IndexerSettings, - IndexerTestResult, LogResponse, Movie, MovieCommandBody, MovieHistoryItem, QualityProfile, - RadarrSerdeable, Release, ReleaseDownloadBody, RootFolder, SystemStatus, Tag, Task, TaskName, - Update, + IndexerTestResult, Movie, MovieCommandBody, MovieHistoryItem, RadarrSerdeable, + ReleaseDownloadBody, RootFolder, SystemStatus, Tag, Task, TaskName, Update, }; use crate::models::servarr_data::radarr::modals::{ AddMovieModal, EditCollectionModal, EditIndexerModal, EditMovieModal, IndexerTestResultModalItem, MovieDetailsModal, }; use crate::models::servarr_data::radarr::radarr_data::ActiveRadarrBlock; -use crate::models::servarr_models::{HostConfig, Indexer, QueueEvent, SecurityConfig}; +use crate::models::servarr_models::{ + HostConfig, Indexer, LogResponse, QualityProfile, QueueEvent, Release, SecurityConfig, +}; use crate::models::stateful_table::StatefulTable; use crate::models::{HorizontallyScrollableText, Route, Scrollable, ScrollableText}; use crate::network::{Network, NetworkEvent, RequestMethod}; @@ -223,9 +224,10 @@ impl<'a, 'b> Network<'a, 'b> { .get_queued_radarr_events() .await .map(RadarrSerdeable::from), - RadarrEvent::GetReleases(movie_id) => { - self.get_releases(movie_id).await.map(RadarrSerdeable::from) - } + RadarrEvent::GetReleases(movie_id) => self + .get_movie_releases(movie_id) + .await + .map(RadarrSerdeable::from), RadarrEvent::GetRootFolders => self.get_root_folders().await.map(RadarrSerdeable::from), RadarrEvent::GetSecurityConfig => self .get_radarr_security_config() @@ -1750,7 +1752,7 @@ impl<'a, 'b> Network<'a, 'b> { .await } - async fn get_releases(&mut self, movie_id: Option) -> Result> { + async fn get_movie_releases(&mut self, movie_id: Option) -> Result> { let (id, movie_id_param) = self.extract_movie_id(movie_id).await; info!("Fetching releases for movie with ID: {id}"); let event = RadarrEvent::GetReleases(None); diff --git a/src/network/radarr_network_tests.rs b/src/network/radarr_network_tests.rs index c80f719..45b7e8d 100644 --- a/src/network/radarr_network_tests.rs +++ b/src/network/radarr_network_tests.rs @@ -15,11 +15,13 @@ mod test { use crate::app::ServarrConfig; use crate::models::radarr_models::{ - BlocklistItem, BlocklistItemMovie, CollectionMovie, Language, MediaInfo, MinimumAvailability, - Monitor, MovieCollection, MovieFile, Quality, QualityWrapper, Rating, RatingsList, + BlocklistItem, BlocklistItemMovie, CollectionMovie, MediaInfo, MinimumAvailability, Monitor, + MovieCollection, MovieFile, Rating, RatingsList, }; use crate::models::servarr_data::radarr::radarr_data::ActiveRadarrBlock; - use crate::models::servarr_models::{HostConfig, IndexerField}; + use crate::models::servarr_models::{ + HostConfig, IndexerField, Language, Quality, QualityWrapper, + }; use crate::models::stateful_table::SortOption; use crate::models::HorizontallyScrollableText; use crate::network::network_tests::test_utils::mock_servarr_api; diff --git a/src/network/sonarr_network.rs b/src/network/sonarr_network.rs index 2b1676d..19ac1a4 100644 --- a/src/network/sonarr_network.rs +++ b/src/network/sonarr_network.rs @@ -1,18 +1,20 @@ -use std::collections::BTreeMap; - use anyhow::Result; use indoc::formatdoc; use log::{debug, info}; -use managarr_tree_widget::TreeItem; use serde_json::{json, Value}; use crate::{ models::{ - servarr_data::sonarr::{modals::EpisodeDetailsModal, sonarr_data::ActiveSonarrBlock}, - servarr_models::{HostConfig, Indexer, QueueEvent, SecurityConfig}, + servarr_data::sonarr::{ + modals::{EpisodeDetailsModal, SeasonDetailsModal}, + sonarr_data::ActiveSonarrBlock, + }, + servarr_models::{ + HostConfig, Indexer, LogResponse, QualityProfile, QueueEvent, Release, SecurityConfig, + }, sonarr_models::{ - BlocklistResponse, DownloadRecord, DownloadsResponse, Episode, IndexerSettings, LogResponse, - QualityProfile, Series, SonarrSerdeable, SystemStatus, + BlocklistResponse, DownloadRecord, DownloadsResponse, Episode, IndexerSettings, Series, + SonarrSerdeable, SystemStatus, }, HorizontallyScrollableText, Route, Scrollable, ScrollableText, }, @@ -39,6 +41,7 @@ pub enum SonarrEvent { GetLogs(Option), GetQualityProfiles, GetQueuedEvents, + GetSeasonReleases(Option<(i64, i64)>), GetSecurityConfig, GetStatus, HealthCheck, @@ -59,6 +62,7 @@ impl NetworkResource for SonarrEvent { SonarrEvent::GetLogs(_) => "/log", SonarrEvent::GetQualityProfiles => "/qualityprofile", SonarrEvent::GetQueuedEvents => "/command", + SonarrEvent::GetSeasonReleases(_) => "/release", SonarrEvent::GetStatus => "/system/status", SonarrEvent::HealthCheck => "/health", SonarrEvent::ListSeries => "/series", @@ -100,11 +104,15 @@ impl<'a, 'b> Network<'a, 'b> { .get_episode_details(episode_id) .await .map(SonarrSerdeable::from), - SonarrEvent::GetIndexers => self.get_sonarr_indexers().await.map(SonarrSerdeable::from), SonarrEvent::GetHostConfig => self .get_sonarr_host_config() .await .map(SonarrSerdeable::from), + SonarrEvent::GetIndexers => self.get_sonarr_indexers().await.map(SonarrSerdeable::from), + SonarrEvent::GetLogs(events) => self + .get_sonarr_logs(events) + .await + .map(SonarrSerdeable::from), SonarrEvent::GetQualityProfiles => self .get_sonarr_quality_profiles() .await @@ -113,8 +121,8 @@ impl<'a, 'b> Network<'a, 'b> { .get_queued_sonarr_events() .await .map(SonarrSerdeable::from), - SonarrEvent::GetLogs(events) => self - .get_sonarr_logs(events) + SonarrEvent::GetSeasonReleases(params) => self + .get_season_releases(params) .await .map(SonarrSerdeable::from), SonarrEvent::GetSecurityConfig => self @@ -288,43 +296,29 @@ impl<'a, 'b> Network<'a, 'b> { episode_vec.sort_by(|a, b| a.id.cmp(&b.id)); if !matches!( app.get_current_route(), - Route::Sonarr(ActiveSonarrBlock::EpisodesTableSortPrompt, _) + Route::Sonarr(ActiveSonarrBlock::EpisodesSortPrompt, _) ) { + if app.data.sonarr_data.season_details_modal.is_none() { + app.data.sonarr_data.season_details_modal = Some(SeasonDetailsModal::default()); + } + app .data .sonarr_data - .episodes_table + .season_details_modal + .as_mut() + .unwrap() + .episodes .set_items(episode_vec.clone()); app .data .sonarr_data - .episodes_table + .season_details_modal + .as_mut() + .unwrap() + .episodes .apply_sorting_toggle(false); } - - let mut seasons = BTreeMap::new(); - - for episode in episode_vec { - seasons - .entry(episode.season_number) - .or_insert_with(Vec::new) - .push(episode); - } - - let tree = seasons - .into_iter() - .map(|(season, episodes_vec)| { - let marker_episode = Episode { - title: Some(format!("Season {season}")), - ..Episode::default() - }; - let children = episodes_vec.into_iter().map(TreeItem::new_leaf).collect(); - - TreeItem::new(marker_episode, children).expect("All item identifiers must be unique") - }) - .collect(); - - app.data.sonarr_data.episodes_tree.set_items(tree); }) .await } @@ -431,7 +425,15 @@ impl<'a, 'b> Network<'a, 'b> { } }; - app.data.sonarr_data.episode_details_modal = Some(episode_details_modal); + if !app.cli_mode { + app + .data + .sonarr_data + .season_details_modal + .as_mut() + .expect("Season details modal is empty") + .episode_details_modal = Some(episode_details_modal); + } }) .await } @@ -548,6 +550,51 @@ impl<'a, 'b> Network<'a, 'b> { .await } + async fn get_season_releases( + &mut self, + series_season_id_tuple: Option<(i64, i64)>, + ) -> Result> { + let event = SonarrEvent::GetSeasonReleases(None); + let (series_id, season_number) = + if let Some((series_id, season_number)) = series_season_id_tuple { + (Some(series_id), Some(season_number)) + } else { + (None, None) + }; + + let (series_id, series_id_param) = self.extract_series_id(series_id).await; + let (season_number, season_number_param) = self.extract_season_number(season_number).await; + + info!("Fetching releases for series with ID: {series_id} and season number: {season_number}"); + + let request_props = self + .request_props_from( + event, + RequestMethod::Get, + None::<()>, + None, + Some(format!("{}&{}", series_id_param, season_number_param)), + ) + .await; + + self + .handle_request::<(), Vec>(request_props, |release_vec, mut app| { + if app.data.sonarr_data.season_details_modal.is_none() { + app.data.sonarr_data.season_details_modal = Some(SeasonDetailsModal::default()); + } + + app + .data + .sonarr_data + .season_details_modal + .as_mut() + .unwrap() + .season_releases + .set_items(release_vec); + }) + .await + } + async fn get_sonarr_security_config(&mut self) -> Result { info!("Fetching Sonarr security config"); let event = SonarrEvent::GetSecurityConfig; @@ -616,24 +663,38 @@ impl<'a, 'b> Network<'a, 'b> { (series_id, format!("seriesId={series_id}")) } - async fn extract_episode_id(&mut self, episode_id: Option) -> i64 { - let app = self.app.lock().await; - - let episode_id = if let Some(id) = episode_id { - id - } else if matches!( - app.get_current_route(), - Route::Sonarr(ActiveSonarrBlock::EpisodesTable, _) - ) { - app.data.sonarr_data.episodes_table.current_selection().id + async fn extract_season_number(&mut self, season_number: Option) -> (i64, String) { + let season_number = if let Some(number) = season_number { + number } else { - app + self + .app + .lock() + .await .data .sonarr_data - .episodes_tree + .seasons .current_selection() + .season_number + }; + (season_number, format!("seasonNumber={season_number}")) + } + + async fn extract_episode_id(&mut self, episode_id: Option) -> i64 { + let episode_id = if let Some(id) = episode_id { + id + } else { + self + .app + .lock() + .await + .data + .sonarr_data + .season_details_modal .as_ref() - .unwrap() + .expect("Season details have not been loaded") + .episodes + .current_selection() .id }; diff --git a/src/network/sonarr_network_tests.rs b/src/network/sonarr_network_tests.rs index b16200d..2209cc8 100644 --- a/src/network/sonarr_network_tests.rs +++ b/src/network/sonarr_network_tests.rs @@ -21,16 +21,17 @@ mod test { use tokio_util::sync::CancellationToken; use crate::app::App; + use crate::models::servarr_data::sonarr::modals::SeasonDetailsModal; use crate::models::servarr_data::sonarr::sonarr_data::ActiveSonarrBlock; use crate::models::servarr_models::{ - HostConfig, Indexer, IndexerField, QueueEvent, SecurityConfig, + HostConfig, Indexer, IndexerField, Language, LogResponse, Quality, QualityProfile, + QualityWrapper, QueueEvent, Release, SecurityConfig, }; + use crate::models::sonarr_models::BlocklistResponse; + use crate::models::sonarr_models::SystemStatus; use crate::models::sonarr_models::{ - BlocklistItem, DownloadRecord, DownloadsResponse, Episode, EpisodeFile, Language, LogResponse, - MediaInfo, QualityProfile, + BlocklistItem, DownloadRecord, DownloadsResponse, Episode, EpisodeFile, MediaInfo, }; - use crate::models::sonarr_models::{BlocklistResponse, Quality}; - use crate::models::sonarr_models::{QualityWrapper, SystemStatus}; use crate::models::stateful_table::StatefulTable; use crate::models::HorizontallyScrollableText; use crate::models::{sonarr_models::SonarrSerdeable, stateful_table::SortOption}; @@ -157,6 +158,11 @@ mod test { assert_str_eq!(event.resource(), "/indexer"); } + #[rstest] + fn test_resource_release(#[values(SonarrEvent::GetSeasonReleases(None))] event: SonarrEvent) { + assert_str_eq!(event.resource(), "/release"); + } + #[rstest] #[case(SonarrEvent::ClearBlocklist, "/blocklist/bulk")] #[case(SonarrEvent::DeleteBlocklistItem(None), "/blocklist")] @@ -408,14 +414,6 @@ mod test { #[rstest] #[tokio::test] async fn test_handle_get_episodes_event(#[values(true, false)] use_custom_sorting: bool) { - let marker_episode_1 = Episode { - title: Some("Season 1".to_owned()), - ..Episode::default() - }; - let marker_episode_2 = Episode { - title: Some("Season 2".to_owned()), - ..Episode::default() - }; let episode_1 = Episode { title: Some("z test".to_owned()), episode_file: None, @@ -432,18 +430,6 @@ mod test { }; let expected_episodes = vec![episode_1.clone(), episode_2.clone()]; let mut expected_sorted_episodes = vec![episode_1.clone(), episode_2.clone()]; - let expected_tree = vec![ - TreeItem::new( - marker_episode_1, - vec![TreeItem::new_leaf(episode_1.clone())], - ) - .unwrap(), - TreeItem::new( - marker_episode_2, - vec![TreeItem::new_leaf(episode_2.clone())], - ) - .unwrap(), - ]; let (async_server, app_arc, _server) = mock_servarr_api( RequestMethod::Get, None, @@ -454,13 +440,8 @@ mod test { Some("seriesId=1"), ) .await; - app_arc - .lock() - .await - .data - .sonarr_data - .episodes_table - .sort_asc = true; + let mut season_details_modal = SeasonDetailsModal::default(); + season_details_modal.episodes.sort_asc = true; if use_custom_sorting { let cmp_fn = |a: &Episode, b: &Episode| { a.title @@ -474,14 +455,11 @@ mod test { name: "Title", cmp_fn: Some(cmp_fn), }; - app_arc - .lock() - .await - .data - .sonarr_data - .episodes_table + season_details_modal + .episodes .sorting(vec![title_sort_option]); } + app_arc.lock().await.data.sonarr_data.season_details_modal = Some(season_details_modal); app_arc .lock() .await @@ -501,7 +479,16 @@ mod test { { async_server.assert_async().await; assert_eq!( - app_arc.lock().await.data.sonarr_data.episodes_table.items, + app_arc + .lock() + .await + .data + .sonarr_data + .season_details_modal + .as_ref() + .unwrap() + .episodes + .items, expected_sorted_episodes ); assert!( @@ -510,17 +497,63 @@ mod test { .await .data .sonarr_data - .episodes_table + .season_details_modal + .as_ref() + .unwrap() + .episodes .sort_asc ); - assert_eq!( - app_arc.lock().await.data.sonarr_data.episodes_tree.items, - expected_tree - ); assert_eq!(episodes, expected_episodes); } } + #[tokio::test] + async fn test_handle_get_episodes_event_empty_season_details_modal() { + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(json!([episode()])), + None, + SonarrEvent::GetEpisodes(None), + None, + Some("seriesId=1"), + ) + .await; + app_arc + .lock() + .await + .data + .sonarr_data + .series + .set_items(vec![Series { + id: 1, + ..Series::default() + }]); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + if let SonarrSerdeable::Episodes(episodes) = network + .handle_sonarr_event(SonarrEvent::GetEpisodes(None)) + .await + .unwrap() + { + async_server.assert_async().await; + assert_eq!( + app_arc + .lock() + .await + .data + .sonarr_data + .season_details_modal + .as_ref() + .unwrap() + .episodes + .items, + vec![episode()] + ); + assert_eq!(episodes, vec![episode()]); + } + } + #[tokio::test] async fn test_handle_get_episodes_event_no_op_while_user_is_selecting_sort_options_on_table() { let episodes_json = json!([ @@ -551,14 +584,6 @@ mod test { "monitored": true } ]); - let marker_episode_1 = Episode { - title: Some("Season 1".to_owned()), - ..Episode::default() - }; - let marker_episode_2 = Episode { - title: Some("Season 2".to_owned()), - ..Episode::default() - }; let episode_1 = Episode { episode_file: None, ..episode() @@ -572,18 +597,6 @@ mod test { ..episode() }; let mut expected_episodes = vec![episode_2.clone(), episode_1.clone()]; - let expected_tree = vec![ - TreeItem::new( - marker_episode_1, - vec![TreeItem::new_leaf(episode_1.clone())], - ) - .unwrap(), - TreeItem::new( - marker_episode_2, - vec![TreeItem::new_leaf(episode_2.clone())], - ) - .unwrap(), - ]; let (async_server, app_arc, _server) = mock_servarr_api( RequestMethod::Get, None, @@ -597,14 +610,9 @@ mod test { app_arc .lock() .await - .push_navigation_stack(ActiveSonarrBlock::EpisodesTableSortPrompt.into()); - app_arc - .lock() - .await - .data - .sonarr_data - .episodes_table - .sort_asc = true; + .push_navigation_stack(ActiveSonarrBlock::EpisodesSortPrompt.into()); + let mut season_details_modal = SeasonDetailsModal::default(); + season_details_modal.episodes.sort_asc = true; let cmp_fn = |a: &Episode, b: &Episode| { a.title .as_ref() @@ -617,12 +625,8 @@ mod test { name: "Title", cmp_fn: Some(cmp_fn), }; - app_arc - .lock() - .await - .data - .sonarr_data - .episodes_table + season_details_modal + .episodes .sorting(vec![title_sort_option]); app_arc .lock() @@ -634,6 +638,7 @@ mod test { id: 1, ..Series::default() }]); + app_arc.lock().await.data.sonarr_data.season_details_modal = Some(season_details_modal); let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); if let SonarrSerdeable::Episodes(episodes) = network @@ -647,7 +652,10 @@ mod test { .await .data .sonarr_data - .episodes_table + .season_details_modal + .as_ref() + .unwrap() + .episodes .is_empty()); assert!( app_arc @@ -655,13 +663,12 @@ mod test { .await .data .sonarr_data - .episodes_table + .season_details_modal + .as_ref() + .unwrap() + .episodes .sort_asc ); - assert_eq!( - app_arc.lock().await.data.sonarr_data.episodes_tree.items, - expected_tree - ); assert_eq!(episodes, expected_episodes); } } @@ -791,14 +798,6 @@ mod test { "monitored": true } ]); - let marker_episode_1 = Episode { - title: Some("Season 1".to_owned()), - ..Episode::default() - }; - let marker_episode_2 = Episode { - title: Some("Season 2".to_owned()), - ..Episode::default() - }; let episode_1 = Episode { series_id: 2, episode_file: None, @@ -814,18 +813,6 @@ mod test { ..episode() }; let expected_episodes = vec![episode_2.clone(), episode_1.clone()]; - let expected_tree = vec![ - TreeItem::new( - marker_episode_1, - vec![TreeItem::new_leaf(episode_1.clone())], - ) - .unwrap(), - TreeItem::new( - marker_episode_2, - vec![TreeItem::new_leaf(episode_2.clone())], - ) - .unwrap(), - ]; let (async_server, app_arc, _server) = mock_servarr_api( RequestMethod::Get, None, @@ -844,10 +831,6 @@ mod test { .unwrap() { async_server.assert_async().await; - assert_eq!( - app_arc.lock().await.data.sonarr_data.episodes_tree.items, - expected_tree - ); assert_eq!(episodes, expected_episodes); } } @@ -865,17 +848,13 @@ mod test { None, ) .await; + let mut season_details_modal = SeasonDetailsModal::default(); + season_details_modal.episodes.set_items(vec![episode()]); + app_arc.lock().await.data.sonarr_data.season_details_modal = Some(season_details_modal); app_arc .lock() .await - .data - .sonarr_data - .episodes_table - .set_items(vec![episode()]); - app_arc - .lock() - .await - .push_navigation_stack(ActiveSonarrBlock::EpisodesTable.into()); + .push_navigation_stack(ActiveSonarrBlock::Episodes.into()); let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); if let SonarrSerdeable::Episode(episode) = network @@ -889,12 +868,23 @@ mod test { .await .data .sonarr_data + .season_details_modal + .as_ref() + .unwrap() .episode_details_modal .is_some()); assert_eq!(episode, response); let app = app_arc.lock().await; - let episode_details_modal = app.data.sonarr_data.episode_details_modal.as_ref().unwrap(); + let episode_details_modal = app + .data + .sonarr_data + .season_details_modal + .as_ref() + .unwrap() + .episode_details_modal + .as_ref() + .unwrap(); assert_str_eq!( episode_details_modal.episode_details.get_text(), formatdoc!( @@ -955,6 +945,9 @@ mod test { None, ) .await; + let mut season_details_modal = SeasonDetailsModal::default(); + season_details_modal.episodes.set_items(vec![episode()]); + app_arc.lock().await.data.sonarr_data.season_details_modal = Some(season_details_modal); let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); if let SonarrSerdeable::Episode(episode) = network @@ -967,6 +960,75 @@ mod test { } } + #[tokio::test] + async fn test_handle_get_episode_details_event_season_details_modal_not_required_in_cli_mode() { + let response: Episode = serde_json::from_str(EPISODE_JSON).unwrap(); + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(serde_json::from_str(EPISODE_JSON).unwrap()), + None, + SonarrEvent::GetEpisodeDetails(None), + Some("/1"), + None, + ) + .await; + app_arc.lock().await.cli_mode = true; + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + if let SonarrSerdeable::Episode(episode) = network + .handle_sonarr_event(SonarrEvent::GetEpisodeDetails(Some(1))) + .await + .unwrap() + { + async_server.assert_async().await; + assert_eq!(episode, response); + } + } + + #[tokio::test] + #[should_panic(expected = "Season details have not been loaded")] + async fn test_handle_get_episode_details_event_requires_season_details_modal_to_be_some_when_no_parameter_is_passed( + ) { + let (_async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(serde_json::from_str(EPISODE_JSON).unwrap()), + None, + SonarrEvent::GetEpisodeDetails(None), + Some("/1"), + None, + ) + .await; + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + network + .handle_sonarr_event(SonarrEvent::GetEpisodeDetails(None)) + .await + .unwrap(); + } + + #[tokio::test] + #[should_panic(expected = "Season details modal is empty")] + async fn test_handle_get_episode_details_event_requires_season_details_modal_to_be_some() { + let (_async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(serde_json::from_str(EPISODE_JSON).unwrap()), + None, + SonarrEvent::GetEpisodeDetails(None), + Some("/1"), + None, + ) + .await; + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + network + .handle_sonarr_event(SonarrEvent::GetEpisodeDetails(Some(1))) + .await + .unwrap(); + } + #[tokio::test] async fn test_handle_get_sonarr_logs_event() { let expected_logs = vec![ @@ -1188,6 +1250,272 @@ mod test { } } + #[tokio::test] + async fn test_handle_get_season_releases_event() { + let release_json = json!([{ + "guid": "1234", + "protocol": "torrent", + "age": 1, + "title": "Test Release", + "indexer": "kickass torrents", + "indexerId": 2, + "size": 1234, + "rejected": true, + "rejections": [ "Unknown quality profile", "Release is already mapped" ], + "seeders": 2, + "leechers": 1, + "languages": [ { "name": "English" } ], + "quality": { "quality": { "name": "Bluray-1080p" }} + }]); + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(release_json), + None, + SonarrEvent::GetSeasonReleases(None), + None, + Some("seriesId=1&seasonNumber=1"), + ) + .await; + app_arc + .lock() + .await + .data + .sonarr_data + .series + .set_items(vec![series()]); + app_arc + .lock() + .await + .data + .sonarr_data + .seasons + .set_items(vec![season()]); + app_arc.lock().await.data.sonarr_data.season_details_modal = + Some(SeasonDetailsModal::default()); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + if let SonarrSerdeable::Releases(releases_vec) = network + .handle_sonarr_event(SonarrEvent::GetSeasonReleases(None)) + .await + .unwrap() + { + async_server.assert_async().await; + assert_eq!( + app_arc + .lock() + .await + .data + .sonarr_data + .season_details_modal + .as_ref() + .unwrap() + .season_releases + .items, + vec![release()] + ); + assert_eq!(releases_vec, vec![release()]); + } + } + + #[tokio::test] + async fn test_handle_get_season_releases_event_empty_season_details_modal() { + let release_json = json!([{ + "guid": "1234", + "protocol": "torrent", + "age": 1, + "title": "Test Release", + "indexer": "kickass torrents", + "indexerId": 2, + "size": 1234, + "rejected": true, + "rejections": [ "Unknown quality profile", "Release is already mapped" ], + "seeders": 2, + "leechers": 1, + "languages": [ { "name": "English" } ], + "quality": { "quality": { "name": "Bluray-1080p" }} + }]); + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(release_json), + None, + SonarrEvent::GetSeasonReleases(None), + None, + Some("seriesId=1&seasonNumber=1"), + ) + .await; + app_arc + .lock() + .await + .data + .sonarr_data + .series + .set_items(vec![series()]); + app_arc + .lock() + .await + .data + .sonarr_data + .seasons + .set_items(vec![season()]); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + assert!(network + .handle_sonarr_event(SonarrEvent::GetSeasonReleases(None)) + .await + .is_ok()); + + async_server.assert_async().await; + assert_eq!( + app_arc + .lock() + .await + .data + .sonarr_data + .season_details_modal + .as_ref() + .unwrap() + .season_releases + .items, + vec![release()] + ); + } + + #[tokio::test] + async fn test_handle_get_season_releases_event_uses_provided_series_id_and_season_number() { + let release_json = json!([{ + "guid": "1234", + "protocol": "torrent", + "age": 1, + "title": "Test Release", + "indexer": "kickass torrents", + "indexerId": 2, + "size": 1234, + "rejected": true, + "rejections": [ "Unknown quality profile", "Release is already mapped" ], + "seeders": 2, + "leechers": 1, + "languages": [ { "name": "English" } ], + "quality": { "quality": { "name": "Bluray-1080p" }} + }]); + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(release_json), + None, + SonarrEvent::GetSeasonReleases(None), + None, + Some("seriesId=2&seasonNumber=2"), + ) + .await; + app_arc + .lock() + .await + .data + .sonarr_data + .series + .set_items(vec![series()]); + app_arc + .lock() + .await + .data + .sonarr_data + .seasons + .set_items(vec![season()]); + app_arc.lock().await.data.sonarr_data.season_details_modal = + Some(SeasonDetailsModal::default()); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + if let SonarrSerdeable::Releases(releases_vec) = network + .handle_sonarr_event(SonarrEvent::GetSeasonReleases(Some((2, 2)))) + .await + .unwrap() + { + async_server.assert_async().await; + assert_eq!( + app_arc + .lock() + .await + .data + .sonarr_data + .season_details_modal + .as_ref() + .unwrap() + .season_releases + .items, + vec![release()] + ); + assert_eq!(releases_vec, vec![release()]); + } + } + + #[tokio::test] + async fn test_handle_get_season_releases_event_filtered_series_and_filtered_seasons() { + let release_json = json!([{ + "guid": "1234", + "protocol": "torrent", + "age": 1, + "title": "Test Release", + "indexer": "kickass torrents", + "indexerId": 2, + "size": 1234, + "rejected": true, + "rejections": [ "Unknown quality profile", "Release is already mapped" ], + "seeders": 2, + "leechers": 1, + "languages": [ { "name": "English" } ], + "quality": { "quality": { "name": "Bluray-1080p" }} + }]); + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(release_json), + None, + SonarrEvent::GetSeasonReleases(None), + None, + Some("seriesId=1&seasonNumber=1"), + ) + .await; + let mut filtered_series = StatefulTable::default(); + filtered_series.set_filtered_items(vec![Series { + id: 1, + ..Series::default() + }]); + app_arc.lock().await.data.sonarr_data.series = filtered_series; + let mut filtered_seasons = StatefulTable::default(); + filtered_seasons.set_filtered_items(vec![Season { + season_number: 1, + ..Season::default() + }]); + app_arc.lock().await.data.sonarr_data.seasons = filtered_seasons; + app_arc.lock().await.data.sonarr_data.season_details_modal = + Some(SeasonDetailsModal::default()); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + if let SonarrSerdeable::Releases(releases_vec) = network + .handle_sonarr_event(SonarrEvent::GetSeasonReleases(None)) + .await + .unwrap() + { + async_server.assert_async().await; + assert_eq!( + app_arc + .lock() + .await + .data + .sonarr_data + .season_details_modal + .as_ref() + .unwrap() + .season_releases + .items, + vec![release()] + ); + assert_eq!(releases_vec, vec![release()]); + } + } + #[rstest] #[tokio::test] async fn test_handle_get_series_event(#[values(true, false)] use_custom_sorting: bool) { @@ -1459,22 +1787,76 @@ mod test { } #[tokio::test] - async fn test_extract_episode_id() { + async fn test_extract_season_number() { let app_arc = Arc::new(Mutex::new(App::default())); app_arc .lock() .await .data .sonarr_data - .episodes_table - .set_items(vec![Episode { - id: 1, - ..Episode::default() + .seasons + .set_items(vec![Season { + season_number: 1, + ..Season::default() }]); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + let (id, season_number_param) = network.extract_season_number(None).await; + + assert_eq!(id, 1); + assert_str_eq!(season_number_param, "seasonNumber=1"); + } + + #[tokio::test] + async fn test_extract_season_number_uses_provided_season_number() { + let app_arc = Arc::new(Mutex::new(App::default())); app_arc .lock() .await - .push_navigation_stack(ActiveSonarrBlock::EpisodesTable.into()); + .data + .sonarr_data + .seasons + .set_items(vec![Season { + season_number: 1, + ..Season::default() + }]); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + let (id, season_number_param) = network.extract_season_number(Some(2)).await; + + assert_eq!(id, 2); + assert_str_eq!(season_number_param, "seasonNumber=2"); + } + + #[tokio::test] + async fn test_extract_season_number_filtered_seasons() { + let app_arc = Arc::new(Mutex::new(App::default())); + let mut filtered_seasons = StatefulTable::default(); + filtered_seasons.set_filtered_items(vec![Season { + season_number: 1, + ..Season::default() + }]); + app_arc.lock().await.data.sonarr_data.seasons = filtered_seasons; + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + let (id, season_number_param) = network.extract_season_number(None).await; + + assert_eq!(id, 1); + assert_str_eq!(season_number_param, "seasonNumber=1"); + } + + #[tokio::test] + async fn test_extract_episode_id() { + let app_arc = Arc::new(Mutex::new(App::default())); + let mut season_details_modal = SeasonDetailsModal::default(); + season_details_modal.episodes.set_items(vec![Episode { + id: 1, + ..Episode::default() + }]); + app_arc.lock().await.data.sonarr_data.season_details_modal = Some(season_details_modal); + app_arc + .lock() + .await + .push_navigation_stack(ActiveSonarrBlock::Episodes.into()); let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); let id = network.extract_episode_id(None).await; @@ -1485,20 +1867,16 @@ mod test { #[tokio::test] async fn test_extract_episode_id_uses_provided_id() { let app_arc = Arc::new(Mutex::new(App::default())); + let mut season_details_modal = SeasonDetailsModal::default(); + season_details_modal.episodes.set_items(vec![Episode { + id: 1, + ..Episode::default() + }]); + app_arc.lock().await.data.sonarr_data.season_details_modal = Some(season_details_modal); app_arc .lock() .await - .data - .sonarr_data - .episodes_table - .set_items(vec![Episode { - id: 1, - ..Episode::default() - }]); - app_arc - .lock() - .await - .push_navigation_stack(ActiveSonarrBlock::EpisodesTable.into()); + .push_navigation_stack(ActiveSonarrBlock::Episodes.into()); let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); let id = network.extract_episode_id(Some(2)).await; @@ -1514,11 +1892,15 @@ mod test { id: 1, ..Episode::default() }]); - app_arc.lock().await.data.sonarr_data.episodes_table = filtered_episodes; + let season_details_modal = SeasonDetailsModal { + episodes: filtered_episodes, + ..SeasonDetailsModal::default() + }; + app_arc.lock().await.data.sonarr_data.season_details_modal = Some(season_details_modal); app_arc .lock() .await - .push_navigation_stack(ActiveSonarrBlock::EpisodesTable.into()); + .push_navigation_stack(ActiveSonarrBlock::Episodes.into()); let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); let id = network.extract_episode_id(None).await; @@ -1526,60 +1908,6 @@ mod test { assert_eq!(id, 1); } - #[tokio::test] - async fn test_extract_episode_id_from_tree() { - let app_arc = Arc::new(Mutex::new(App::default())); - { - let mut app = app_arc.lock().await; - let items = vec![TreeItem::new_leaf(Episode { - id: 1, - ..Episode::default() - })]; - app.data.sonarr_data.episodes_tree.set_items(items.clone()); - render( - &mut app.data.sonarr_data.episodes_tree.state, - &items.clone(), - ); - app.data.sonarr_data.episodes_tree.state.key_down(); - render( - &mut app.data.sonarr_data.episodes_tree.state, - &items.clone(), - ); - } - let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); - - let id = network.extract_episode_id(None).await; - - assert_eq!(id, 1); - } - - #[tokio::test] - async fn test_extract_episode_id_uses_provided_id_over_tree() { - let app_arc = Arc::new(Mutex::new(App::default())); - { - let mut app = app_arc.lock().await; - let items = vec![TreeItem::new_leaf(Episode { - id: 1, - ..Episode::default() - })]; - app.data.sonarr_data.episodes_tree.set_items(items.clone()); - render( - &mut app.data.sonarr_data.episodes_tree.state, - &items.clone(), - ); - app.data.sonarr_data.episodes_tree.state.key_down(); - render( - &mut app.data.sonarr_data.episodes_tree.state, - &items.clone(), - ); - } - let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); - - let id = network.extract_episode_id(Some(2)).await; - - assert_eq!(id, 2); - } - #[test] fn test_get_episode_status_downloaded() { assert_str_eq!(get_episode_status(true, &[], 0), "Downloaded"); @@ -1831,6 +2159,31 @@ mod test { } } + fn rejections() -> Vec { + vec![ + "Unknown quality profile".to_owned(), + "Release is already mapped".to_owned(), + ] + } + + fn release() -> Release { + Release { + guid: "1234".to_owned(), + protocol: "torrent".to_owned(), + age: 1, + title: HorizontallyScrollableText::from("Test Release"), + indexer: "kickass torrents".to_owned(), + indexer_id: 2, + size: 1234, + rejected: true, + rejections: Some(rejections()), + seeders: Some(Number::from(2)), + leechers: Some(Number::from(1)), + languages: Some(vec![language()]), + quality: quality_wrapper(), + } + } + fn render(state: &mut TreeState, items: &[TreeItem]) where T: ToText + Clone + Default + Display + Hash + PartialEq + Eq, diff --git a/src/ui/radarr_ui/library/movie_details_ui.rs b/src/ui/radarr_ui/library/movie_details_ui.rs index b8218bf..36699a6 100644 --- a/src/ui/radarr_ui/library/movie_details_ui.rs +++ b/src/ui/radarr_ui/library/movie_details_ui.rs @@ -7,9 +7,10 @@ use ratatui::widgets::{Cell, Paragraph, Row, Wrap}; use ratatui::Frame; use crate::app::App; -use crate::models::radarr_models::{Credit, MovieHistoryItem, Release}; +use crate::models::radarr_models::{Credit, MovieHistoryItem}; use crate::models::servarr_data::radarr::modals::MovieDetailsModal; use crate::models::servarr_data::radarr::radarr_data::{ActiveRadarrBlock, MOVIE_DETAILS_BLOCKS}; +use crate::models::servarr_models::Release; use crate::models::Route; use crate::ui::radarr_ui::library::draw_library; use crate::ui::styles::ManagarrStyle; From 16bf06426fcdbf66d4b280272b914674e6f3867f Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Tue, 19 Nov 2024 16:29:25 -0700 Subject: [PATCH 022/119] fix(config): The CLI panics if the servarr you specify has no config defined --- src/app/app_tests.rs | 24 +++++++---- src/app/mod.rs | 38 ++++++++++++++--- src/main.rs | 18 ++++++-- src/network/mod.rs | 10 ++++- src/network/network_tests.rs | 64 ++++++++++++++++++++++++----- src/network/radarr_network_tests.rs | 2 +- 6 files changed, 124 insertions(+), 32 deletions(-) diff --git a/src/app/app_tests.rs b/src/app/app_tests.rs index 6b98a5e..8975ea0 100644 --- a/src/app/app_tests.rs +++ b/src/app/app_tests.rs @@ -5,7 +5,7 @@ mod tests { use tokio::sync::mpsc; use crate::app::context_clues::{build_context_clue_string, SERVARR_CONTEXT_CLUES}; - use crate::app::{App, Data, ServarrConfig, DEFAULT_ROUTE}; + use crate::app::{App, AppConfig, Data, ServarrConfig, DEFAULT_ROUTE}; use crate::models::servarr_data::radarr::radarr_data::{ActiveRadarrBlock, RadarrData}; use crate::models::servarr_data::sonarr::sonarr_data::{ActiveSonarrBlock, SonarrData}; use crate::models::{HorizontallyScrollableText, TabRoute}; @@ -255,13 +255,21 @@ mod tests { } #[test] - fn test_servarr_config_default() { - let radarr_config = ServarrConfig::default(); + fn test_app_config_default() { + let app_config = AppConfig::default(); - assert_eq!(radarr_config.host, Some("localhost".to_string())); - assert_eq!(radarr_config.port, None); - assert_eq!(radarr_config.uri, None); - assert!(radarr_config.api_token.is_empty()); - assert_eq!(radarr_config.ssl_cert_path, None); + assert!(app_config.radarr.is_none()); + assert!(app_config.sonarr.is_none()); + } + + #[test] + fn test_servarr_config_default() { + let servarr_config = ServarrConfig::default(); + + assert_eq!(servarr_config.host, Some("localhost".to_string())); + assert_eq!(servarr_config.port, None); + assert_eq!(servarr_config.uri, None); + assert!(servarr_config.api_token.is_empty()); + assert_eq!(servarr_config.ssl_cert_path, None); } } diff --git a/src/app/mod.rs b/src/app/mod.rs index ba95fcc..f401e1d 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -8,6 +8,7 @@ use tokio::sync::mpsc::Sender; use tokio_util::sync::CancellationToken; use crate::app::context_clues::{build_context_clue_string, SERVARR_CONTEXT_CLUES}; +use crate::cli::Command; use crate::models::servarr_data::radarr::radarr_data::{ActiveRadarrBlock, RadarrData}; use crate::models::servarr_data::sonarr::sonarr_data::{ActiveSonarrBlock, SonarrData}; use crate::models::{HorizontallyScrollableText, Route, TabRoute, TabState}; @@ -178,20 +179,45 @@ pub struct Data<'a> { pub sonarr_data: SonarrData, } -#[derive(Debug, Deserialize, Serialize, Default)] +#[derive(Debug, Deserialize, Serialize, Default, Clone)] pub struct AppConfig { - pub radarr: ServarrConfig, - pub sonarr: ServarrConfig, + pub radarr: Option, + pub sonarr: Option, } impl AppConfig { pub fn validate(&self) { - self.radarr.validate(); + if let Some(radarr_config) = &self.radarr { + radarr_config.validate(); + } + + if let Some(sonarr_config) = &self.sonarr { + sonarr_config.validate(); + } + } + + pub fn verify_config_present_for_cli(&self, command: &Command) { + let msg = |servarr: &str| { + log_and_print_error(format!( + "{} configuration missing; Unable to run any {} commands.", + servarr, servarr + )) + }; + match command { + Command::Radarr(_) if self.radarr.is_none() => { + msg("Radarr"); + process::exit(1); + } + Command::Sonarr(_) if self.sonarr.is_none() => { + msg("Sonarr"); + process::exit(1); + } + _ => (), + } } } -#[derive(Debug, Deserialize, Serialize)] -#[cfg_attr(test, derive(Clone))] +#[derive(Debug, Deserialize, Serialize, Clone)] pub struct ServarrConfig { pub host: Option, pub port: Option, diff --git a/src/main.rs b/src/main.rs index b45d3e8..b6ece85 100644 --- a/src/main.rs +++ b/src/main.rs @@ -106,13 +106,14 @@ async fn main() -> Result<()> { let app = Arc::new(Mutex::new(App::new( sync_network_tx, - config, + config.clone(), cancellation_token.clone(), ))); match args.command { Some(command) => match command { Command::Radarr(_) | Command::Sonarr(_) => { + config.verify_config_present_for_cli(&command); app.lock().await.cli_mode = true; let app_nw = Arc::clone(&app); let mut network = Network::new(&app_nw, cancellation_token, reqwest_client); @@ -249,9 +250,18 @@ fn build_network_client(config: &AppConfig) -> Client { .http2_keep_alive_interval(Duration::from_secs(5)) .tcp_keepalive(Duration::from_secs(5)); - if let Some(ref cert_path) = config.radarr.ssl_cert_path { - let cert = create_cert(cert_path, "Radarr"); - client_builder = client_builder.add_root_certificate(cert); + if let Some(radarr_config) = &config.radarr { + if let Some(ref cert_path) = &radarr_config.ssl_cert_path { + let cert = create_cert(cert_path, "Radarr"); + client_builder = client_builder.add_root_certificate(cert); + } + } + + if let Some(sonarr_config) = &config.sonarr { + if let Some(ref cert_path) = &sonarr_config.ssl_cert_path { + let cert = create_cert(cert_path, "Sonarr"); + client_builder = client_builder.add_root_certificate(cert); + } } match client_builder.build() { diff --git a/src/network/mod.rs b/src/network/mod.rs index 0967a46..92b4f98 100644 --- a/src/network/mod.rs +++ b/src/network/mod.rs @@ -216,8 +216,14 @@ impl<'a, 'b> Network<'a, 'b> { }, default_port, ) = match network_event.into() { - NetworkEvent::Radarr(_) => (&app.config.radarr, 7878), - NetworkEvent::Sonarr(_) => (&app.config.sonarr, 8989), + NetworkEvent::Radarr(_) => ( + &app.config.radarr.as_ref().expect("Radarr config undefined"), + 7878, + ), + NetworkEvent::Sonarr(_) => ( + &app.config.sonarr.as_ref().expect("Sonarr config undefined"), + 8989, + ), }; let mut uri = if let Some(servarr_uri) = uri { format!("{servarr_uri}/api/v3{resource}") diff --git a/src/network/network_tests.rs b/src/network/network_tests.rs index 109ac71..1613ca0 100644 --- a/src/network/network_tests.rs +++ b/src/network/network_tests.rs @@ -43,7 +43,7 @@ mod tests { ssl_cert_path: None, ..ServarrConfig::default() }; - app.config.radarr = radarr_config; + app.config.radarr = Some(radarr_config); let app_arc = Arc::new(Mutex::new(app)); let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); @@ -397,6 +397,40 @@ mod tests { async_server.assert_async().await; } + #[tokio::test] + #[should_panic(expected = "Radarr config undefined")] + async fn test_request_props_from_requires_radarr_config_to_be_present_for_radarr_events() { + let app_arc = Arc::new(Mutex::new(App::default())); + let network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + network + .request_props_from( + RadarrEvent::GetMovies, + RequestMethod::Get, + None::<()>, + None, + None, + ) + .await; + } + + #[tokio::test] + #[should_panic(expected = "Sonarr config undefined")] + async fn test_request_props_from_requires_sonarr_config_to_be_present_for_sonarr_events() { + let app_arc = Arc::new(Mutex::new(App::default())); + let network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + network + .request_props_from( + SonarrEvent::ListSeries, + RequestMethod::Get, + None::<()>, + None, + None, + ) + .await; + } + #[rstest] #[case(RadarrEvent::GetMovies, 7878)] #[case(SonarrEvent::ListSeries, 8989)] @@ -408,6 +442,10 @@ mod tests { let app_arc = Arc::new(Mutex::new(App::default())); let network = Network::new(&app_arc, CancellationToken::new(), Client::new()); let resource = network_event.resource(); + app_arc.lock().await.config = AppConfig { + radarr: Some(ServarrConfig::default()), + sonarr: Some(ServarrConfig::default()), + }; let request_props = network .request_props_from(network_event, RequestMethod::Get, None::<()>, None, None) @@ -440,8 +478,8 @@ mod tests { }; { let mut app = app_arc.lock().await; - app.config.radarr = servarr_config.clone(); - app.config.sonarr = servarr_config; + app.config.radarr = Some(servarr_config.clone()); + app.config.sonarr = Some(servarr_config); } let network = Network::new(&app_arc, CancellationToken::new(), Client::new()); @@ -474,8 +512,8 @@ mod tests { }; { let mut app = app_arc.lock().await; - app.config.radarr = servarr_config.clone(); - app.config.sonarr = servarr_config; + app.config.radarr = Some(servarr_config.clone()); + app.config.sonarr = Some(servarr_config); } let network = Network::new(&app_arc, CancellationToken::new(), Client::new()); @@ -503,6 +541,10 @@ mod tests { let app_arc = Arc::new(Mutex::new(App::default())); let network = Network::new(&app_arc, CancellationToken::new(), Client::new()); let resource = network_event.resource(); + app_arc.lock().await.config = AppConfig { + radarr: Some(ServarrConfig::default()), + sonarr: Some(ServarrConfig::default()), + }; let request_props = network .request_props_from( @@ -541,8 +583,8 @@ mod tests { }; { let mut app = app_arc.lock().await; - app.config.radarr = servarr_config.clone(); - app.config.sonarr = servarr_config; + app.config.radarr = Some(servarr_config.clone()); + app.config.sonarr = Some(servarr_config); } let network = Network::new(&app_arc, CancellationToken::new(), Client::new()); @@ -581,8 +623,8 @@ mod tests { }; { let mut app = app_arc.lock().await; - app.config.radarr = servarr_config.clone(); - app.config.sonarr = servarr_config; + app.config.radarr = Some(servarr_config.clone()); + app.config.sonarr = Some(servarr_config); } let network = Network::new(&app_arc, CancellationToken::new(), Client::new()); @@ -701,8 +743,8 @@ pub(in crate::network) mod test_utils { }; match network_event.into() { - NetworkEvent::Radarr(_) => app.config.radarr = servarr_config, - NetworkEvent::Sonarr(_) => app.config.sonarr = servarr_config, + NetworkEvent::Radarr(_) => app.config.radarr = Some(servarr_config), + NetworkEvent::Sonarr(_) => app.config.sonarr = Some(servarr_config), } let app_arc = Arc::new(Mutex::new(app)); diff --git a/src/network/radarr_network_tests.rs b/src/network/radarr_network_tests.rs index 45b7e8d..6dc5809 100644 --- a/src/network/radarr_network_tests.rs +++ b/src/network/radarr_network_tests.rs @@ -827,7 +827,7 @@ mod test { api_token: "test1234".to_owned(), ..ServarrConfig::default() }; - app.config.radarr = radarr_config; + app.config.radarr = Some(radarr_config); let app_arc = Arc::new(Mutex::new(app)); let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); From 540db5993b7a6569305a54aee4faa94062901cd9 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Tue, 19 Nov 2024 16:39:21 -0700 Subject: [PATCH 023/119] feat(cli): Added support for manually searching for season releases for Sonarr --- src/cli/radarr/mod.rs | 2 +- src/cli/sonarr/mod.rs | 23 ++++++++ src/cli/sonarr/sonarr_command_tests.rs | 80 ++++++++++++++++++++++++++ 3 files changed, 104 insertions(+), 1 deletion(-) diff --git a/src/cli/radarr/mod.rs b/src/cli/radarr/mod.rs index 9bc654c..96c7938 100644 --- a/src/cli/radarr/mod.rs +++ b/src/cli/radarr/mod.rs @@ -86,7 +86,7 @@ pub enum RadarrCommand { ManualSearch { #[arg( long, - help = "The Radarr ID of the movie whose releases you wish to fetch and list", + help = "The Radarr ID of the movie whose releases you wish to fetch", required = true )] movie_id: i64, diff --git a/src/cli/sonarr/mod.rs b/src/cli/sonarr/mod.rs index fdb03e2..0b8e438 100644 --- a/src/cli/sonarr/mod.rs +++ b/src/cli/sonarr/mod.rs @@ -42,6 +42,19 @@ pub enum SonarrCommand { List(SonarrListCommand), #[command(about = "Clear the blocklist")] ClearBlocklist, + #[command( + about = "Trigger a manual search of releases for the given season corresponding to the series with the given ID" + )] + ManualSeasonSearch { + #[arg( + long, + help = "The Sonarr ID of the series whose releases you wish to fetch and list", + required = true + )] + series_id: i64, + #[arg(long, help = "The season number to search for", required = true)] + season_number: i64, + }, } impl From for Command { @@ -93,6 +106,16 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, SonarrCommand> for SonarrCliHandler<'a, ' .await?; execute_network_event!(self, SonarrEvent::ClearBlocklist); } + SonarrCommand::ManualSeasonSearch { + series_id, + season_number, + } => { + println!("Searching for season releases. This may take a minute..."); + execute_network_event!( + self, + SonarrEvent::GetSeasonReleases(Some((series_id, season_number))) + ); + } } Ok(()) diff --git a/src/cli/sonarr/sonarr_command_tests.rs b/src/cli/sonarr/sonarr_command_tests.rs index 4d1d4f6..d61b749 100644 --- a/src/cli/sonarr/sonarr_command_tests.rs +++ b/src/cli/sonarr/sonarr_command_tests.rs @@ -18,6 +18,7 @@ mod tests { mod cli { use super::*; + use clap::error::ErrorKind; use rstest::rstest; #[rstest] @@ -26,6 +27,55 @@ mod tests { assert!(result.is_ok()); } + + #[rstest] + fn test_manual_season_search_requires_series_id() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "sonarr", + "manual-season-search", + "--season-number", + "1", + ]); + + assert!(result.is_err()); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[rstest] + fn test_manual_season_search_requires_season_number() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "sonarr", + "manual-season-search", + "--series-id", + "1", + ]); + + assert!(result.is_err()); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_manual_season_search_requirements_satisfied() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "sonarr", + "manual-season-search", + "--series-id", + "1", + "--season-number", + "1", + ]); + + assert!(result.is_ok()); + } } mod handler { @@ -84,6 +134,36 @@ mod tests { assert!(result.is_ok()); } + #[tokio::test] + async fn test_manual_season_search_command() { + let expected_series_id = 1; + let expected_season_number = 1; + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::( + SonarrEvent::GetSeasonReleases(Some((expected_series_id, expected_season_number))).into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Sonarr(SonarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::default())); + let manual_season_search_command = SonarrCommand::ManualSeasonSearch { + series_id: 1, + season_number: 1, + }; + + let result = + SonarrCliHandler::with(&app_arc, manual_season_search_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; From 5e63c34a9fcc2b6334988cd4a1fb23b710f181db Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Tue, 19 Nov 2024 16:56:48 -0700 Subject: [PATCH 024/119] feat(network): Added support for fetching series details for a given series ID in Sonarr --- src/models/sonarr_models.rs | 2 + src/models/sonarr_models_tests.rs | 18 +++++- src/network/sonarr_network.rs | 27 ++++++++- src/network/sonarr_network_tests.rs | 88 ++++++++++++++++++++++++----- 4 files changed, 118 insertions(+), 17 deletions(-) diff --git a/src/models/sonarr_models.rs b/src/models/sonarr_models.rs index ca74870..673e459 100644 --- a/src/models/sonarr_models.rs +++ b/src/models/sonarr_models.rs @@ -320,6 +320,7 @@ pub enum SonarrSerdeable { Releases(Vec), SecurityConfig(SecurityConfig), SeriesVec(Vec), + Series(Series), SystemStatus(SystemStatus), BlocklistResponse(BlocklistResponse), LogResponse(LogResponse), @@ -351,6 +352,7 @@ serde_enum_from!( Releases(Vec), SecurityConfig(SecurityConfig), SeriesVec(Vec), + Series(Series), SystemStatus(SystemStatus), BlocklistResponse(BlocklistResponse), LogResponse(LogResponse), diff --git a/src/models/sonarr_models_tests.rs b/src/models/sonarr_models_tests.rs index 9d864e2..2fbf265 100644 --- a/src/models/sonarr_models_tests.rs +++ b/src/models/sonarr_models_tests.rs @@ -144,15 +144,27 @@ mod tests { } #[test] - fn test_sonarr_serdeable_from_series() { - let series = vec![Series { + fn test_sonarr_serdeable_from_series_vec() { + let series_vec = vec![Series { id: 1, ..Series::default() }]; + let sonarr_serdeable: SonarrSerdeable = series_vec.clone().into(); + + assert_eq!(sonarr_serdeable, SonarrSerdeable::SeriesVec(series_vec)); + } + + #[test] + fn test_sonarr_serdeable_from_series() { + let series = Series { + id: 1, + ..Series::default() + }; + let sonarr_serdeable: SonarrSerdeable = series.clone().into(); - assert_eq!(sonarr_serdeable, SonarrSerdeable::SeriesVec(series)); + assert_eq!(sonarr_serdeable, SonarrSerdeable::Series(series)); } #[test] diff --git a/src/network/sonarr_network.rs b/src/network/sonarr_network.rs index 19ac1a4..45e298d 100644 --- a/src/network/sonarr_network.rs +++ b/src/network/sonarr_network.rs @@ -43,6 +43,7 @@ pub enum SonarrEvent { GetQueuedEvents, GetSeasonReleases(Option<(i64, i64)>), GetSecurityConfig, + GetSeriesDetails(Option), GetStatus, HealthCheck, ListSeries, @@ -65,7 +66,7 @@ impl NetworkResource for SonarrEvent { SonarrEvent::GetSeasonReleases(_) => "/release", SonarrEvent::GetStatus => "/system/status", SonarrEvent::HealthCheck => "/health", - SonarrEvent::ListSeries => "/series", + SonarrEvent::ListSeries | SonarrEvent::GetSeriesDetails(_) => "/series", } } } @@ -129,6 +130,10 @@ impl<'a, 'b> Network<'a, 'b> { .get_sonarr_security_config() .await .map(SonarrSerdeable::from), + SonarrEvent::GetSeriesDetails(series_id) => self + .get_series_details(series_id) + .await + .map(SonarrSerdeable::from), SonarrEvent::GetStatus => self.get_sonarr_status().await.map(SonarrSerdeable::from), SonarrEvent::HealthCheck => self .get_sonarr_healthcheck() @@ -608,6 +613,26 @@ impl<'a, 'b> Network<'a, 'b> { .await } + async fn get_series_details(&mut self, series_id: Option) -> Result { + let (id, _) = self.extract_series_id(series_id).await; + info!("Fetching details for Sonarr series with ID: {id}"); + let event = SonarrEvent::GetSeriesDetails(series_id); + + let request_props = self + .request_props_from( + event, + RequestMethod::Get, + None::<()>, + Some(format!("/{id}")), + None, + ) + .await; + + self + .handle_request::<(), Series>(request_props, |_, _| ()) + .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 2209cc8..b57548a 100644 --- a/src/network/sonarr_network_tests.rs +++ b/src/network/sonarr_network_tests.rs @@ -137,7 +137,9 @@ mod test { } #[rstest] - fn test_resource_series(#[values(SonarrEvent::ListSeries)] event: SonarrEvent) { + fn test_resource_series( + #[values(SonarrEvent::ListSeries, SonarrEvent::GetSeriesDetails(None))] event: SonarrEvent, + ) { assert_str_eq!(event.resource(), "/series"); } @@ -1010,7 +1012,8 @@ mod test { #[tokio::test] #[should_panic(expected = "Season details modal is empty")] - async fn test_handle_get_episode_details_event_requires_season_details_modal_to_be_some() { + async fn test_handle_get_episode_details_event_requires_season_details_modal_to_be_some_when_in_tui_mode( + ) { let (_async_server, app_arc, _server) = mock_servarr_api( RequestMethod::Get, None, @@ -1518,7 +1521,7 @@ mod test { #[rstest] #[tokio::test] - async fn test_handle_get_series_event(#[values(true, false)] use_custom_sorting: bool) { + async fn test_handle_list_series_event(#[values(true, false)] use_custom_sorting: bool) { let mut series_1: Value = serde_json::from_str(SERIES_JSON).unwrap(); let mut series_2: Value = serde_json::from_str(SERIES_JSON).unwrap(); *series_1.get_mut("id").unwrap() = json!(1); @@ -1597,6 +1600,75 @@ mod test { } } + #[tokio::test] + async fn test_handle_get_series_details_event() { + let expected_series: Series = serde_json::from_str(SERIES_JSON).unwrap(); + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(serde_json::from_str(SERIES_JSON).unwrap()), + None, + SonarrEvent::GetSeriesDetails(None), + Some("/1"), + None, + ) + .await; + app_arc + .lock() + .await + .data + .sonarr_data + .series + .set_items(vec![series()]); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + if let SonarrSerdeable::Series(series) = network + .handle_sonarr_event(SonarrEvent::GetSeriesDetails(None)) + .await + .unwrap() + { + async_server.assert_async().await; + assert_eq!(series, expected_series); + } + } + + #[tokio::test] + async fn test_handle_get_series_details_event_uses_provided_series_id() { + let expected_series: Series = Series { + id: 2, + ..serde_json::from_str(SERIES_JSON).unwrap() + }; + let mut response: Value = serde_json::from_str(SERIES_JSON).unwrap(); + *response.get_mut("id").unwrap() = json!(2); + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(response), + None, + SonarrEvent::GetSeriesDetails(Some(2)), + Some("/2"), + None, + ) + .await; + app_arc + .lock() + .await + .data + .sonarr_data + .series + .set_items(vec![series()]); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + if let SonarrSerdeable::Series(series) = network + .handle_sonarr_event(SonarrEvent::GetSeriesDetails(Some(2))) + .await + .unwrap() + { + async_server.assert_async().await; + assert_eq!(series, expected_series); + } + } + #[tokio::test] async fn test_handle_get_series_event_no_op_while_user_is_selecting_sort_options() { let mut series_1: Value = serde_json::from_str(SERIES_JSON).unwrap(); @@ -2183,14 +2255,4 @@ mod test { quality: quality_wrapper(), } } - - fn render(state: &mut TreeState, items: &[TreeItem]) - where - T: ToText + Clone + Default + Display + Hash + PartialEq + Eq, - { - let tree = Tree::new(items).unwrap(); - let area = Rect::new(0, 0, 10, 4); - let mut buffer = Buffer::empty(area); - StatefulWidget::render(tree, area, &mut buffer, state); - } } From d82a7f7674cbf828d5c28a777c847edc15b92f28 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Tue, 19 Nov 2024 17:01:48 -0700 Subject: [PATCH 025/119] feat(cli): Added CLI support for fetching series details in Sonarr --- src/cli/sonarr/get_command_handler.rs | 12 +++++ src/cli/sonarr/get_command_handler_tests.rs | 52 +++++++++++++++++++++ 2 files changed, 64 insertions(+) diff --git a/src/cli/sonarr/get_command_handler.rs b/src/cli/sonarr/get_command_handler.rs index 8d29cbc..7af1ee6 100644 --- a/src/cli/sonarr/get_command_handler.rs +++ b/src/cli/sonarr/get_command_handler.rs @@ -34,6 +34,15 @@ pub enum SonarrGetCommand { HostConfig, #[command(about = "Fetch the security config for your Sonarr instance")] SecurityConfig, + #[command(about = "Get detailed information for the series with the given ID")] + SeriesDetails { + #[arg( + long, + help = "The Sonarr ID of the series whose details you wish to fetch", + required = true + )] + series_id: i64, + }, #[command(about = "Get the system status")] SystemStatus, } @@ -77,6 +86,9 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, SonarrGetCommand> for SonarrGetCommandHan SonarrGetCommand::SecurityConfig => { execute_network_event!(self, SonarrEvent::GetSecurityConfig); } + SonarrGetCommand::SeriesDetails { series_id } => { + execute_network_event!(self, SonarrEvent::GetSeriesDetails(Some(series_id))); + } SonarrGetCommand::SystemStatus => { execute_network_event!(self, SonarrEvent::GetStatus); } diff --git a/src/cli/sonarr/get_command_handler_tests.rs b/src/cli/sonarr/get_command_handler_tests.rs index 8905048..714cc67 100644 --- a/src/cli/sonarr/get_command_handler_tests.rs +++ b/src/cli/sonarr/get_command_handler_tests.rs @@ -78,6 +78,32 @@ mod tests { assert!(result.is_ok()); } + + #[test] + fn test_series_details_requires_series_id() { + let result = + Cli::command().try_get_matches_from(["managarr", "sonarr", "get", "series-details"]); + + assert!(result.is_err()); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_series_details_requirements_satisfied() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "sonarr", + "get", + "series-details", + "--series-id", + "1", + ]); + + assert!(result.is_ok()); + } } mod handler { @@ -197,6 +223,32 @@ mod tests { assert!(result.is_ok()); } + #[tokio::test] + async fn test_handle_get_series_details_command() { + let expected_series_id = 1; + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::( + SonarrEvent::GetSeriesDetails(Some(expected_series_id)).into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Sonarr(SonarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::default())); + let get_series_details_command = SonarrGetCommand::SeriesDetails { series_id: 1 }; + + let result = + SonarrGetCommandHandler::with(&app_arc, get_series_details_command, &mut mock_network) + .handle() + .await; + + assert!(result.is_ok()); + } + #[tokio::test] async fn test_handle_get_system_status_command() { let mut mock_network = MockNetworkTrait::new(); From a8328d36365f7590a88cc9bb2b219f02ee480e17 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Tue, 19 Nov 2024 17:17:12 -0700 Subject: [PATCH 026/119] feat(network): Added support for fetching episode releases in Sonarr --- src/network/sonarr_network.rs | 62 ++++++- src/network/sonarr_network_tests.rs | 245 +++++++++++++++++++++++++++- 2 files changed, 297 insertions(+), 10 deletions(-) diff --git a/src/network/sonarr_network.rs b/src/network/sonarr_network.rs index 45e298d..dd924a6 100644 --- a/src/network/sonarr_network.rs +++ b/src/network/sonarr_network.rs @@ -41,6 +41,7 @@ pub enum SonarrEvent { GetLogs(Option), GetQualityProfiles, GetQueuedEvents, + GetEpisodeReleases(Option), GetSeasonReleases(Option<(i64, i64)>), GetSecurityConfig, GetSeriesDetails(Option), @@ -63,7 +64,7 @@ impl NetworkResource for SonarrEvent { SonarrEvent::GetLogs(_) => "/log", SonarrEvent::GetQualityProfiles => "/qualityprofile", SonarrEvent::GetQueuedEvents => "/command", - SonarrEvent::GetSeasonReleases(_) => "/release", + SonarrEvent::GetSeasonReleases(_) | SonarrEvent::GetEpisodeReleases(_) => "/release", SonarrEvent::GetStatus => "/system/status", SonarrEvent::HealthCheck => "/health", SonarrEvent::ListSeries | SonarrEvent::GetSeriesDetails(_) => "/series", @@ -122,6 +123,10 @@ impl<'a, 'b> Network<'a, 'b> { .get_queued_sonarr_events() .await .map(SonarrSerdeable::from), + SonarrEvent::GetEpisodeReleases(params) => self + .get_episode_releases(params) + .await + .map(SonarrSerdeable::from), SonarrEvent::GetSeasonReleases(params) => self .get_season_releases(params) .await @@ -555,6 +560,61 @@ impl<'a, 'b> Network<'a, 'b> { .await } + async fn get_episode_releases(&mut self, episode_id: Option) -> Result> { + let event = SonarrEvent::GetEpisodeReleases(None); + let id = self.extract_episode_id(episode_id).await; + + info!("Fetching releases for episode with ID: {id}"); + + let request_props = self + .request_props_from( + event, + RequestMethod::Get, + None::<()>, + None, + Some(format!("episodeId={id}")), + ) + .await; + + self + .handle_request::<(), Vec>(request_props, |release_vec, mut app| { + if app.data.sonarr_data.season_details_modal.is_none() { + app.data.sonarr_data.season_details_modal = Some(SeasonDetailsModal::default()); + } + + if app + .data + .sonarr_data + .season_details_modal + .as_mut() + .unwrap() + .episode_details_modal + .is_none() + { + app + .data + .sonarr_data + .season_details_modal + .as_mut() + .unwrap() + .episode_details_modal = Some(EpisodeDetailsModal::default()); + } + + app + .data + .sonarr_data + .season_details_modal + .as_mut() + .unwrap() + .episode_details_modal + .as_mut() + .unwrap() + .episode_releases + .set_items(release_vec); + }) + .await + } + async fn get_season_releases( &mut self, series_season_id_tuple: Option<(i64, i64)>, diff --git a/src/network/sonarr_network_tests.rs b/src/network/sonarr_network_tests.rs index b57548a..4eb93fb 100644 --- a/src/network/sonarr_network_tests.rs +++ b/src/network/sonarr_network_tests.rs @@ -1,18 +1,11 @@ #[cfg(test)] mod test { - use std::fmt::Display; - use std::hash::Hash; use std::sync::Arc; use bimap::BiMap; use chrono::{DateTime, Utc}; use indoc::formatdoc; - use managarr_tree_widget::{Tree, TreeItem, TreeState}; use pretty_assertions::{assert_eq, assert_str_eq}; - use ratatui::buffer::Buffer; - use ratatui::layout::Rect; - use ratatui::text::ToText; - use ratatui::widgets::StatefulWidget; use reqwest::Client; use rstest::rstest; use serde_json::json; @@ -21,7 +14,7 @@ mod test { use tokio_util::sync::CancellationToken; use crate::app::App; - use crate::models::servarr_data::sonarr::modals::SeasonDetailsModal; + use crate::models::servarr_data::sonarr::modals::{EpisodeDetailsModal, SeasonDetailsModal}; use crate::models::servarr_data::sonarr::sonarr_data::ActiveSonarrBlock; use crate::models::servarr_models::{ HostConfig, Indexer, IndexerField, Language, LogResponse, Quality, QualityProfile, @@ -161,7 +154,13 @@ mod test { } #[rstest] - fn test_resource_release(#[values(SonarrEvent::GetSeasonReleases(None))] event: SonarrEvent) { + fn test_resource_release( + #[values( + SonarrEvent::GetSeasonReleases(None), + SonarrEvent::GetEpisodeReleases(None) + )] + event: SonarrEvent, + ) { assert_str_eq!(event.resource(), "/release"); } @@ -1253,6 +1252,234 @@ mod test { } } + #[tokio::test] + async fn test_handle_get_episode_releases_event() { + let release_json = json!([{ + "guid": "1234", + "protocol": "torrent", + "age": 1, + "title": "Test Release", + "indexer": "kickass torrents", + "indexerId": 2, + "size": 1234, + "rejected": true, + "rejections": [ "Unknown quality profile", "Release is already mapped" ], + "seeders": 2, + "leechers": 1, + "languages": [ { "name": "English" } ], + "quality": { "quality": { "name": "Bluray-1080p" }} + }]); + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(release_json), + None, + SonarrEvent::GetEpisodeReleases(None), + None, + Some("episodeId=1"), + ) + .await; + let mut season_details_modal = SeasonDetailsModal::default(); + season_details_modal.episodes.set_items(vec![episode()]); + app_arc.lock().await.data.sonarr_data.season_details_modal = Some(season_details_modal); + app_arc + .lock() + .await + .data + .sonarr_data + .season_details_modal + .as_mut() + .unwrap() + .episode_details_modal = Some(EpisodeDetailsModal::default()); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + if let SonarrSerdeable::Releases(releases_vec) = network + .handle_sonarr_event(SonarrEvent::GetEpisodeReleases(None)) + .await + .unwrap() + { + async_server.assert_async().await; + assert_eq!( + app_arc + .lock() + .await + .data + .sonarr_data + .season_details_modal + .as_ref() + .unwrap() + .episode_details_modal + .as_ref() + .unwrap() + .episode_releases + .items, + vec![release()] + ); + assert_eq!(releases_vec, vec![release()]); + } + } + + #[tokio::test] + async fn test_handle_get_episode_releases_event_empty_episode_details_modal() { + let release_json = json!([{ + "guid": "1234", + "protocol": "torrent", + "age": 1, + "title": "Test Release", + "indexer": "kickass torrents", + "indexerId": 2, + "size": 1234, + "rejected": true, + "rejections": [ "Unknown quality profile", "Release is already mapped" ], + "seeders": 2, + "leechers": 1, + "languages": [ { "name": "English" } ], + "quality": { "quality": { "name": "Bluray-1080p" }} + }]); + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(release_json), + None, + SonarrEvent::GetEpisodeReleases(None), + None, + Some("episodeId=1"), + ) + .await; + let mut season_details_modal = SeasonDetailsModal::default(); + season_details_modal.episodes.set_items(vec![episode()]); + app_arc.lock().await.data.sonarr_data.season_details_modal = Some(season_details_modal); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + if let SonarrSerdeable::Releases(releases_vec) = network + .handle_sonarr_event(SonarrEvent::GetEpisodeReleases(None)) + .await + .unwrap() + { + async_server.assert_async().await; + assert_eq!( + app_arc + .lock() + .await + .data + .sonarr_data + .season_details_modal + .as_ref() + .unwrap() + .episode_details_modal + .as_ref() + .unwrap() + .episode_releases + .items, + vec![release()] + ); + assert_eq!(releases_vec, vec![release()]); + } + } + + #[tokio::test] + #[should_panic(expected = "Season details have not been loaded")] + async fn test_handle_get_episode_releases_event_empty_season_details_modal_panics() { + let release_json = json!([{ + "guid": "1234", + "protocol": "torrent", + "age": 1, + "title": "Test Release", + "indexer": "kickass torrents", + "indexerId": 2, + "size": 1234, + "rejected": true, + "rejections": [ "Unknown quality profile", "Release is already mapped" ], + "seeders": 2, + "leechers": 1, + "languages": [ { "name": "English" } ], + "quality": { "quality": { "name": "Bluray-1080p" }} + }]); + let (_async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(release_json), + None, + SonarrEvent::GetEpisodeReleases(None), + None, + Some("episodeId=1"), + ) + .await; + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + network + .handle_sonarr_event(SonarrEvent::GetEpisodeReleases(None)) + .await + .unwrap(); + } + + #[tokio::test] + async fn test_handle_get_episode_releases_event_uses_provided_series_id() { + let release_json = json!([{ + "guid": "1234", + "protocol": "torrent", + "age": 1, + "title": "Test Release", + "indexer": "kickass torrents", + "indexerId": 2, + "size": 1234, + "rejected": true, + "rejections": [ "Unknown quality profile", "Release is already mapped" ], + "seeders": 2, + "leechers": 1, + "languages": [ { "name": "English" } ], + "quality": { "quality": { "name": "Bluray-1080p" }} + }]); + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(release_json), + None, + SonarrEvent::GetEpisodeReleases(None), + None, + Some("episodeId=2"), + ) + .await; + let mut season_details_modal = SeasonDetailsModal::default(); + season_details_modal.episodes.set_items(vec![episode()]); + app_arc.lock().await.data.sonarr_data.season_details_modal = Some(season_details_modal); + app_arc + .lock() + .await + .data + .sonarr_data + .season_details_modal + .as_mut() + .unwrap() + .episode_details_modal = Some(EpisodeDetailsModal::default()); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + if let SonarrSerdeable::Releases(releases_vec) = network + .handle_sonarr_event(SonarrEvent::GetEpisodeReleases(Some(2))) + .await + .unwrap() + { + async_server.assert_async().await; + assert_eq!( + app_arc + .lock() + .await + .data + .sonarr_data + .season_details_modal + .as_ref() + .unwrap() + .episode_details_modal + .as_ref() + .unwrap() + .episode_releases + .items, + vec![release()] + ); + assert_eq!(releases_vec, vec![release()]); + } + } + #[tokio::test] async fn test_handle_get_season_releases_event() { let release_json = json!([{ From 268cc13d27f4f1b4817c57089a50ffeb8a78997c Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Tue, 19 Nov 2024 17:22:27 -0700 Subject: [PATCH 027/119] feat(cli): Added support for manually searching for episode releases in Sonarr --- src/cli/sonarr/mod.rs | 13 +++++++ src/cli/sonarr/sonarr_command_tests.rs | 51 ++++++++++++++++++++++++++ 2 files changed, 64 insertions(+) diff --git a/src/cli/sonarr/mod.rs b/src/cli/sonarr/mod.rs index 0b8e438..693ae01 100644 --- a/src/cli/sonarr/mod.rs +++ b/src/cli/sonarr/mod.rs @@ -42,6 +42,15 @@ pub enum SonarrCommand { List(SonarrListCommand), #[command(about = "Clear the blocklist")] ClearBlocklist, + #[command(about = "Trigger a manual search of releases for the episode with the given ID")] + ManualEpisodeSearch { + #[arg( + long, + help = "The Sonarr ID of the episode whose releases you wish to fetch and list", + required = true + )] + episode_id: i64, + }, #[command( about = "Trigger a manual search of releases for the given season corresponding to the series with the given ID" )] @@ -106,6 +115,10 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, SonarrCommand> for SonarrCliHandler<'a, ' .await?; execute_network_event!(self, SonarrEvent::ClearBlocklist); } + SonarrCommand::ManualEpisodeSearch { episode_id } => { + println!("Searching for episode releases. This may take a minute..."); + execute_network_event!(self, SonarrEvent::GetEpisodeReleases(Some(episode_id))); + } SonarrCommand::ManualSeasonSearch { series_id, season_number, diff --git a/src/cli/sonarr/sonarr_command_tests.rs b/src/cli/sonarr/sonarr_command_tests.rs index d61b749..cf22b4b 100644 --- a/src/cli/sonarr/sonarr_command_tests.rs +++ b/src/cli/sonarr/sonarr_command_tests.rs @@ -76,6 +76,31 @@ mod tests { assert!(result.is_ok()); } + + #[rstest] + fn test_manual_episode_search_requires_episode_id() { + let result = + Cli::command().try_get_matches_from(["managarr", "sonarr", "manual-episode-search"]); + + assert!(result.is_err()); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_manual_episode_search_requirements_satisfied() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "sonarr", + "manual-episode-search", + "--episode-id", + "1", + ]); + + assert!(result.is_ok()); + } } mod handler { @@ -134,6 +159,32 @@ mod tests { assert!(result.is_ok()); } + #[tokio::test] + async fn test_manual_episode_search_command() { + let expected_episode_id = 1; + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::( + SonarrEvent::GetEpisodeReleases(Some(expected_episode_id)).into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Sonarr(SonarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::default())); + let manual_episode_search_command = SonarrCommand::ManualEpisodeSearch { episode_id: 1 }; + + let result = + SonarrCliHandler::with(&app_arc, manual_episode_search_command, &mut mock_network) + .handle() + .await; + + assert!(result.is_ok()); + } + #[tokio::test] async fn test_manual_season_search_command() { let expected_series_id = 1; From a711c3d16ce178331fe548df23e6fc610f6462e3 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Tue, 19 Nov 2024 17:24:41 -0700 Subject: [PATCH 028/119] chore(modals): Removed the unnecessary season_details field from the SeasonDetailsModal --- src/models/servarr_data/sonarr/modals.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/models/servarr_data/sonarr/modals.rs b/src/models/servarr_data/sonarr/modals.rs index 7d27bda..e0d367a 100644 --- a/src/models/servarr_data/sonarr/modals.rs +++ b/src/models/servarr_data/sonarr/modals.rs @@ -16,7 +16,6 @@ pub struct EpisodeDetailsModal { #[derive(Default)] pub struct SeasonDetailsModal { - pub season_details: ScrollableText, pub episodes: StatefulTable, pub episode_details_modal: Option, pub season_releases: StatefulTable, From 9d782af020f3b38db5fc5a9bcbb5ebc84e023797 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Wed, 20 Nov 2024 13:12:08 -0700 Subject: [PATCH 029/119] feat(models): Stubbed out the necessary ActiveSonarrBlocks for the UI mockup --- src/models/servarr_data/sonarr/modals.rs | 2 - src/models/servarr_data/sonarr/sonarr_data.rs | 71 +++++++++++++++++-- src/models/servarr_models.rs | 2 +- src/models/sonarr_models.rs | 34 ++++++++- src/network/sonarr_network_tests.rs | 8 +-- 5 files changed, 101 insertions(+), 16 deletions(-) diff --git a/src/models/servarr_data/sonarr/modals.rs b/src/models/servarr_data/sonarr/modals.rs index e0d367a..4745db9 100644 --- a/src/models/servarr_data/sonarr/modals.rs +++ b/src/models/servarr_data/sonarr/modals.rs @@ -9,8 +9,6 @@ pub struct EpisodeDetailsModal { pub audio_details: String, pub video_details: String, // pub episode_history: StatefulTable, - // pub episode_cast: StatefulTable, - // pub episode_crew: StatefulTable, pub episode_releases: StatefulTable, } diff --git a/src/models/servarr_data/sonarr/sonarr_data.rs b/src/models/servarr_data/sonarr/sonarr_data.rs index 88fb209..d9a8419 100644 --- a/src/models/servarr_data/sonarr/sonarr_data.rs +++ b/src/models/servarr_data/sonarr/sonarr_data.rs @@ -52,15 +52,74 @@ impl Default for SonarrData { #[derive(Clone, Copy, PartialEq, Eq, Debug, Default, EnumIter)] pub enum ActiveSonarrBlock { - Blocklist, - BlocklistSortPrompt, - Episodes, - EpisodesSortPrompt, - Seasons, - SeasonsSortPrompt, #[default] Series, + UpdateAndScanSeriesPrompt, + EditSeriesPrompt, SeriesSortPrompt, + SearchSeries, + SearchSeriesError, + FilterSeries, + FilterSeriesError, + DeleteSeriesPrompt, + DeleteSeriesConfirmPrompt, + AutomaticallySearchSeriesPrompt, + UpdateAllSeriesPrompt, + SeriesDetails, + SeriesHistory, + HistoryDetails, + MarkHistoryItemAsFailurePrompt, + MarkHistoryItemAsFailureConfirmPrompt, + SearchSeason, + SearchSeasonError, + AutomaticallySearchSeasonPrompt, + SeasonDetails, + SeasonHistory, + ManualSeasonSearch, + ManualSeasonSearchSortPrompt, + ManualSeasonSearchConfirmPrompt, + DeleteEpisodeFilePrompt, + EpisodeDetails, + EpisodesSortPrompt, + SearchEpisodes, + SearchEpisodesError, + FilterEpisodes, + FilterEpisodesError, + AutomaticallySearchEpisodePrompt, + EditEpisodePrompt, + EpisodeHistory, + EpisodeFile, + ManualEpisodeSearch, + ManualEpisodeSearchSortPrompt, + ManualEpisodeSearchConfirmPrompt, + AddSeriesPrompt, + AddSeriesSearchInput, + AddSeriesSearchResults, + AddSeriesAlreadyInLibrary, + AddSeriesEmptySearchResults, + AddSeriesConfirmPrompt, + Downloads, + DeleteDownloadPrompt, + Blocklist, + BlocklistClearAllItemsPrompt, + BlocklistItemDetails, + BlocklistSortPrompt, + DeleteBlocklistItemPrompt, + RootFolders, + AddRootFolderPrompt, + DeleteRootFolderPrompt, + Indexers, + DeleteIndexerPrompt, + EditIndexerPrompt, + AllIndexerSettingsPrompt, + TestIndexer, + TestAllIndexers, + System, + SystemTasks, + SystemTaskStartConfirmPrompt, + SystemLogs, + SystemQueuedEvents, + SystemUpdates, } impl From for Route { diff --git a/src/models/servarr_models.rs b/src/models/servarr_models.rs index 909199f..82392d7 100644 --- a/src/models/servarr_models.rs +++ b/src/models/servarr_models.rs @@ -114,7 +114,7 @@ pub struct IndexerField { pub value: Option, } -#[derive(Serialize, Deserialize, Default, Hash, Debug, Clone, PartialEq, Eq, Ord, PartialOrd)] +#[derive(Serialize, Deserialize, Default, Debug, Clone, PartialEq, Eq, Ord, PartialOrd)] pub struct Language { pub name: String, } diff --git a/src/models/sonarr_models.rs b/src/models/sonarr_models.rs index 673e459..01e381a 100644 --- a/src/models/sonarr_models.rs +++ b/src/models/sonarr_models.rs @@ -68,7 +68,7 @@ pub struct DownloadsResponse { pub records: Vec, } -#[derive(Default, Serialize, Deserialize, Hash, Debug, Clone, PartialEq, Eq)] +#[derive(Default, Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct Episode { #[serde(deserialize_with = "super::from_i64")] @@ -97,7 +97,7 @@ impl Display for Episode { } } -#[derive(Default, Serialize, Deserialize, Hash, Debug, Clone, PartialEq, Eq)] +#[derive(Default, Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct EpisodeFile { pub relative_path: String, @@ -124,7 +124,7 @@ pub struct IndexerSettings { pub rss_sync_interval: i64, } -#[derive(Serialize, Deserialize, Derivative, Hash, Debug, Clone, PartialEq, Eq)] +#[derive(Serialize, Deserialize, Derivative, Debug, Clone, PartialEq, Eq)] #[derivative(Default)] #[serde(rename_all = "camelCase")] pub struct MediaInfo { @@ -304,6 +304,34 @@ impl SeriesStatus { } } +#[derive(Serialize, Deserialize, Default, Debug, Clone, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct SonarrEpisodeHistoryResponse { + pub records: Vec, +} + +#[derive(Serialize, Deserialize, Default, Debug, Clone, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct SonarrHistoryData { + pub dropped_path: String, + pub imported_path: String, +} + +#[derive(Serialize, Deserialize, Default, Debug, Clone, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct SonarrHistoryItem { + #[serde(deserialize_with = "super::from_i64")] + pub id: i64, + pub source_title: HorizontallyScrollableText, + #[serde(deserialize_with = "super::from_i64")] + pub episode_id: i64, + pub quality: QualityWrapper, + pub languages: Vec, + pub date: DateTime, + pub event_type: String, + pub data: SonarrHistoryData, +} + #[derive(Clone, Serialize, Deserialize, Debug, PartialEq, Eq)] #[serde(untagged)] #[allow(clippy::large_enum_variant)] diff --git a/src/network/sonarr_network_tests.rs b/src/network/sonarr_network_tests.rs index 4eb93fb..cfb3033 100644 --- a/src/network/sonarr_network_tests.rs +++ b/src/network/sonarr_network_tests.rs @@ -855,7 +855,7 @@ mod test { app_arc .lock() .await - .push_navigation_stack(ActiveSonarrBlock::Episodes.into()); + .push_navigation_stack(ActiveSonarrBlock::EpisodeDetails.into()); let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); if let SonarrSerdeable::Episode(episode) = network @@ -2155,7 +2155,7 @@ mod test { app_arc .lock() .await - .push_navigation_stack(ActiveSonarrBlock::Episodes.into()); + .push_navigation_stack(ActiveSonarrBlock::EpisodeDetails.into()); let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); let id = network.extract_episode_id(None).await; @@ -2175,7 +2175,7 @@ mod test { app_arc .lock() .await - .push_navigation_stack(ActiveSonarrBlock::Episodes.into()); + .push_navigation_stack(ActiveSonarrBlock::EpisodeDetails.into()); let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); let id = network.extract_episode_id(Some(2)).await; @@ -2199,7 +2199,7 @@ mod test { app_arc .lock() .await - .push_navigation_stack(ActiveSonarrBlock::Episodes.into()); + .push_navigation_stack(ActiveSonarrBlock::EpisodeDetails.into()); let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); let id = network.extract_episode_id(None).await; From bd2d2875a5337442967aa42d818eee1db7ba2bb1 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Wed, 20 Nov 2024 13:24:44 -0700 Subject: [PATCH 030/119] feat(models): Added an additional History tab to the mocked tabs for viewing all Sonarr history at once --- src/models/servarr_data/sonarr/sonarr_data.rs | 123 ++++++++++-------- .../servarr_data/sonarr/sonarr_data_tests.rs | 2 + src/models/sonarr_models.rs | 2 +- 3 files changed, 71 insertions(+), 56 deletions(-) diff --git a/src/models/servarr_data/sonarr/sonarr_data.rs b/src/models/servarr_data/sonarr/sonarr_data.rs index d9a8419..99ee413 100644 --- a/src/models/servarr_data/sonarr/sonarr_data.rs +++ b/src/models/servarr_data/sonarr/sonarr_data.rs @@ -4,7 +4,10 @@ use strum::EnumIter; use crate::models::{ servarr_models::{Indexer, QueueEvent}, - sonarr_models::{BlocklistItem, DownloadRecord, IndexerSettings, Season, Series}, + sonarr_models::{ + BlocklistItem, DownloadRecord, IndexerSettings, Season, Series, SonarrHistoryItem, + SonarrHistoryWrapper, + }, stateful_list::StatefulList, stateful_table::StatefulTable, HorizontallyScrollableText, Route, @@ -19,6 +22,7 @@ mod sonarr_data_tests; pub struct SonarrData { pub blocklist: StatefulTable, pub downloads: StatefulTable, + pub history: StatefulTable, pub indexers: StatefulTable, pub indexer_settings: Option, pub logs: StatefulList, @@ -27,6 +31,7 @@ pub struct SonarrData { pub seasons: StatefulTable, pub season_details_modal: Option, pub series: StatefulTable, + pub series_history: Option>, pub start_time: DateTime, pub version: String, } @@ -36,14 +41,16 @@ impl Default for SonarrData { SonarrData { blocklist: StatefulTable::default(), downloads: StatefulTable::default(), + history: StatefulTable::default(), indexers: StatefulTable::default(), indexer_settings: None, logs: StatefulList::default(), quality_profile_map: BiMap::new(), queued_events: StatefulTable::default(), seasons: StatefulTable::default(), - series: StatefulTable::default(), season_details_modal: None, + series: StatefulTable::default(), + series_history: None, start_time: DateTime::default(), version: String::new(), } @@ -53,73 +60,79 @@ impl Default for SonarrData { #[derive(Clone, Copy, PartialEq, Eq, Debug, Default, EnumIter)] pub enum ActiveSonarrBlock { #[default] - Series, - UpdateAndScanSeriesPrompt, - EditSeriesPrompt, - SeriesSortPrompt, - SearchSeries, - SearchSeriesError, - FilterSeries, - FilterSeriesError, - DeleteSeriesPrompt, - DeleteSeriesConfirmPrompt, - AutomaticallySearchSeriesPrompt, - UpdateAllSeriesPrompt, - SeriesDetails, - SeriesHistory, - HistoryDetails, - MarkHistoryItemAsFailurePrompt, - MarkHistoryItemAsFailureConfirmPrompt, - SearchSeason, - SearchSeasonError, - AutomaticallySearchSeasonPrompt, - SeasonDetails, - SeasonHistory, - ManualSeasonSearch, - ManualSeasonSearchSortPrompt, - ManualSeasonSearchConfirmPrompt, - DeleteEpisodeFilePrompt, - EpisodeDetails, - EpisodesSortPrompt, - SearchEpisodes, - SearchEpisodesError, - FilterEpisodes, - FilterEpisodesError, - AutomaticallySearchEpisodePrompt, - EditEpisodePrompt, - EpisodeHistory, - EpisodeFile, - ManualEpisodeSearch, - ManualEpisodeSearchSortPrompt, - ManualEpisodeSearchConfirmPrompt, + AddRootFolderPrompt, + AddSeriesAlreadyInLibrary, + AddSeriesConfirmPrompt, + AddSeriesEmptySearchResults, AddSeriesPrompt, AddSeriesSearchInput, AddSeriesSearchResults, - AddSeriesAlreadyInLibrary, - AddSeriesEmptySearchResults, - AddSeriesConfirmPrompt, - Downloads, - DeleteDownloadPrompt, + AllIndexerSettingsPrompt, + AutomaticallySearchEpisodePrompt, + AutomaticallySearchSeasonPrompt, + AutomaticallySearchSeriesPrompt, Blocklist, BlocklistClearAllItemsPrompt, BlocklistItemDetails, BlocklistSortPrompt, DeleteBlocklistItemPrompt, - RootFolders, - AddRootFolderPrompt, - DeleteRootFolderPrompt, - Indexers, + DeleteDownloadPrompt, + DeleteEpisodeFilePrompt, DeleteIndexerPrompt, + DeleteRootFolderPrompt, + DeleteSeriesConfirmPrompt, + DeleteSeriesPrompt, + Downloads, + EditEpisodePrompt, EditIndexerPrompt, - AllIndexerSettingsPrompt, - TestIndexer, - TestAllIndexers, + EditSeriesPrompt, + EpisodeDetails, + EpisodeFile, + EpisodeHistory, + EpisodesSortPrompt, + FilterEpisodes, + FilterEpisodesError, + FilterHistory, + FilterHistoryError, + FilterSeries, + FilterSeriesError, + History, + HistoryDetails, + HistorySortPrompt, + Indexers, + ManualEpisodeSearch, + ManualEpisodeSearchConfirmPrompt, + ManualEpisodeSearchSortPrompt, + ManualSeasonSearch, + ManualSeasonSearchConfirmPrompt, + ManualSeasonSearchSortPrompt, + MarkHistoryItemAsFailureConfirmPrompt, + MarkHistoryItemAsFailurePrompt, + RootFolders, + SearchEpisodes, + SearchEpisodesError, + SearchHistory, + SearchHistoryError, + SearchSeason, + SearchSeasonError, + SearchSeries, + SearchSeriesError, + SeasonDetails, + SeasonHistory, + Series, + SeriesDetails, + SeriesHistory, + SeriesSortPrompt, System, - SystemTasks, - SystemTaskStartConfirmPrompt, SystemLogs, SystemQueuedEvents, + SystemTasks, + SystemTaskStartConfirmPrompt, SystemUpdates, + TestAllIndexers, + TestIndexer, + UpdateAllSeriesPrompt, + UpdateAndScanSeriesPrompt, } impl From for Route { diff --git a/src/models/servarr_data/sonarr/sonarr_data_tests.rs b/src/models/servarr_data/sonarr/sonarr_data_tests.rs index c4dd645..0ad5ce2 100644 --- a/src/models/servarr_data/sonarr/sonarr_data_tests.rs +++ b/src/models/servarr_data/sonarr/sonarr_data_tests.rs @@ -36,6 +36,7 @@ mod tests { assert!(sonarr_data.blocklist.is_empty()); assert!(sonarr_data.downloads.is_empty()); + assert!(sonarr_data.history.is_empty()); assert!(sonarr_data.indexers.is_empty()); assert!(sonarr_data.indexer_settings.is_none()); assert!(sonarr_data.logs.is_empty()); @@ -44,6 +45,7 @@ mod tests { assert!(sonarr_data.seasons.is_empty()); assert!(sonarr_data.season_details_modal.is_none()); assert!(sonarr_data.series.is_empty()); + assert!(sonarr_data.series_history.is_empty()); assert_eq!(sonarr_data.start_time, >::default()); assert!(sonarr_data.version.is_empty()); } diff --git a/src/models/sonarr_models.rs b/src/models/sonarr_models.rs index 01e381a..07ebf9d 100644 --- a/src/models/sonarr_models.rs +++ b/src/models/sonarr_models.rs @@ -306,7 +306,7 @@ impl SeriesStatus { #[derive(Serialize, Deserialize, Default, Debug, Clone, PartialEq, Eq)] #[serde(rename_all = "camelCase")] -pub struct SonarrEpisodeHistoryResponse { +pub struct SonarrHistoryWrapper { pub records: Vec, } From b8c60bf59a2424456a3f1541f4172c28bcf8a5a1 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Wed, 20 Nov 2024 13:25:24 -0700 Subject: [PATCH 031/119] test(models): Fixed the test for the default series_history value in the sonarr_data --- src/models/servarr_data/sonarr/sonarr_data_tests.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/models/servarr_data/sonarr/sonarr_data_tests.rs b/src/models/servarr_data/sonarr/sonarr_data_tests.rs index 0ad5ce2..90051cb 100644 --- a/src/models/servarr_data/sonarr/sonarr_data_tests.rs +++ b/src/models/servarr_data/sonarr/sonarr_data_tests.rs @@ -45,7 +45,7 @@ mod tests { assert!(sonarr_data.seasons.is_empty()); assert!(sonarr_data.season_details_modal.is_none()); assert!(sonarr_data.series.is_empty()); - assert!(sonarr_data.series_history.is_empty()); + assert!(sonarr_data.series_history.is_none()); assert_eq!(sonarr_data.start_time, >::default()); assert!(sonarr_data.version.is_empty()); } From 6da1ae93ef68a58a1573e9ff0424dfcb2ee93d5e Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Wed, 20 Nov 2024 14:06:44 -0700 Subject: [PATCH 032/119] feat(network): Support to fetch all Sonarr history events --- src/models/servarr_data/sonarr/sonarr_data.rs | 3 +- src/models/sonarr_models.rs | 2 + src/models/sonarr_models_tests.rs | 19 +- src/network/sonarr_network.rs | 35 +- src/network/sonarr_network_tests.rs | 338 +++++++++++++++++- 5 files changed, 392 insertions(+), 5 deletions(-) diff --git a/src/models/servarr_data/sonarr/sonarr_data.rs b/src/models/servarr_data/sonarr/sonarr_data.rs index 99ee413..d60b52e 100644 --- a/src/models/servarr_data/sonarr/sonarr_data.rs +++ b/src/models/servarr_data/sonarr/sonarr_data.rs @@ -6,7 +6,6 @@ use crate::models::{ servarr_models::{Indexer, QueueEvent}, sonarr_models::{ BlocklistItem, DownloadRecord, IndexerSettings, Season, Series, SonarrHistoryItem, - SonarrHistoryWrapper, }, stateful_list::StatefulList, stateful_table::StatefulTable, @@ -22,7 +21,7 @@ mod sonarr_data_tests; pub struct SonarrData { pub blocklist: StatefulTable, pub downloads: StatefulTable, - pub history: StatefulTable, + pub history: StatefulTable, pub indexers: StatefulTable, pub indexer_settings: Option, pub logs: StatefulList, diff --git a/src/models/sonarr_models.rs b/src/models/sonarr_models.rs index 07ebf9d..6b019ca 100644 --- a/src/models/sonarr_models.rs +++ b/src/models/sonarr_models.rs @@ -349,6 +349,7 @@ pub enum SonarrSerdeable { SecurityConfig(SecurityConfig), SeriesVec(Vec), Series(Series), + SonarrHistoryWrapper(SonarrHistoryWrapper), SystemStatus(SystemStatus), BlocklistResponse(BlocklistResponse), LogResponse(LogResponse), @@ -381,6 +382,7 @@ serde_enum_from!( SecurityConfig(SecurityConfig), SeriesVec(Vec), Series(Series), + SonarrHistoryWrapper(SonarrHistoryWrapper), SystemStatus(SystemStatus), BlocklistResponse(BlocklistResponse), LogResponse(LogResponse), diff --git a/src/models/sonarr_models_tests.rs b/src/models/sonarr_models_tests.rs index 2fbf265..19676e8 100644 --- a/src/models/sonarr_models_tests.rs +++ b/src/models/sonarr_models_tests.rs @@ -9,7 +9,8 @@ mod tests { }, sonarr_models::{ BlocklistItem, BlocklistResponse, DownloadRecord, DownloadsResponse, Episode, - IndexerSettings, Series, SeriesStatus, SeriesType, SonarrSerdeable, SystemStatus, + IndexerSettings, Series, SeriesStatus, SeriesType, SonarrHistoryItem, SonarrHistoryWrapper, + SonarrSerdeable, SystemStatus, }, Serdeable, }; @@ -167,6 +168,22 @@ mod tests { assert_eq!(sonarr_serdeable, SonarrSerdeable::Series(series)); } + #[test] + fn test_sonarr_serdeable_from_sonarr_history_wrapper() { + let history_wrapper = SonarrHistoryWrapper { + records: vec![SonarrHistoryItem { + id: 1, + ..SonarrHistoryItem::default() + }], + }; + let sonarr_serdeable: SonarrSerdeable = history_wrapper.clone().into(); + + assert_eq!( + sonarr_serdeable, + SonarrSerdeable::SonarrHistoryWrapper(history_wrapper) + ); + } + #[test] fn test_sonarr_serdeable_from_system_status() { let system_status = SystemStatus { diff --git a/src/network/sonarr_network.rs b/src/network/sonarr_network.rs index dd924a6..b81e851 100644 --- a/src/network/sonarr_network.rs +++ b/src/network/sonarr_network.rs @@ -14,7 +14,7 @@ use crate::{ }, sonarr_models::{ BlocklistResponse, DownloadRecord, DownloadsResponse, Episode, IndexerSettings, Series, - SonarrSerdeable, SystemStatus, + SonarrHistoryWrapper, SonarrSerdeable, SystemStatus, }, HorizontallyScrollableText, Route, Scrollable, ScrollableText, }, @@ -34,6 +34,7 @@ pub enum SonarrEvent { GetAllIndexerSettings, GetBlocklist, GetDownloads, + GetHistory(Option), GetHostConfig, GetIndexers, GetEpisodeDetails(Option), @@ -59,6 +60,7 @@ impl NetworkResource for SonarrEvent { SonarrEvent::GetBlocklist => "/blocklist?page=1&pageSize=10000", SonarrEvent::GetDownloads => "/queue", SonarrEvent::GetEpisodes(_) | SonarrEvent::GetEpisodeDetails(_) => "/episode", + SonarrEvent::GetHistory(_) => "/history", SonarrEvent::GetHostConfig | SonarrEvent::GetSecurityConfig => "/config/host", SonarrEvent::GetIndexers => "/indexer", SonarrEvent::GetLogs(_) => "/log", @@ -106,6 +108,10 @@ impl<'a, 'b> Network<'a, 'b> { .get_episode_details(episode_id) .await .map(SonarrSerdeable::from), + SonarrEvent::GetHistory(items) => self + .get_sonarr_history(items) + .await + .map(SonarrSerdeable::from), SonarrEvent::GetHostConfig => self .get_sonarr_host_config() .await @@ -461,6 +467,33 @@ impl<'a, 'b> Network<'a, 'b> { .await } + async fn get_sonarr_history(&mut self, items: Option) -> Result { + info!("Fetching all Sonarr history events"); + let event = SonarrEvent::GetHistory(items); + + let params = format!( + "pageSize={}&sortDirection=descending&sortKey=time", + items.unwrap_or(500) + ); + let request_props = self + .request_props_from(event, RequestMethod::Get, None::<()>, None, Some(params)) + .await; + + self + .handle_request::<(), SonarrHistoryWrapper>(request_props, |history_response, mut app| { + if !matches!( + app.get_current_route(), + Route::Sonarr(ActiveSonarrBlock::HistorySortPrompt, _) + ) { + let mut history_vec = history_response.records; + history_vec.sort_by(|a, b| a.id.cmp(&b.id)); + app.data.sonarr_data.history.set_items(history_vec); + app.data.sonarr_data.history.apply_sorting_toggle(false); + } + }) + .await + } + async fn get_sonarr_indexers(&mut self) -> Result> { info!("Fetching Sonarr indexers"); let event = SonarrEvent::GetIndexers; diff --git a/src/network/sonarr_network_tests.rs b/src/network/sonarr_network_tests.rs index cfb3033..50adb81 100644 --- a/src/network/sonarr_network_tests.rs +++ b/src/network/sonarr_network_tests.rs @@ -20,11 +20,13 @@ mod test { HostConfig, Indexer, IndexerField, Language, LogResponse, Quality, QualityProfile, QualityWrapper, QueueEvent, Release, SecurityConfig, }; - use crate::models::sonarr_models::BlocklistResponse; use crate::models::sonarr_models::SystemStatus; use crate::models::sonarr_models::{ BlocklistItem, DownloadRecord, DownloadsResponse, Episode, EpisodeFile, MediaInfo, }; + use crate::models::sonarr_models::{ + BlocklistResponse, SonarrHistoryData, SonarrHistoryItem, SonarrHistoryWrapper, + }; use crate::models::stateful_table::StatefulTable; use crate::models::HorizontallyScrollableText; use crate::models::{sonarr_models::SonarrSerdeable, stateful_table::SortOption}; @@ -170,6 +172,7 @@ mod test { #[case(SonarrEvent::HealthCheck, "/health")] #[case(SonarrEvent::GetBlocklist, "/blocklist?page=1&pageSize=10000")] #[case(SonarrEvent::GetDownloads, "/queue")] + #[case(SonarrEvent::GetHistory(None), "/history")] #[case(SonarrEvent::GetLogs(Some(500)), "/log")] #[case(SonarrEvent::GetQualityProfiles, "/qualityprofile")] #[case(SonarrEvent::GetStatus, "/system/status")] @@ -350,6 +353,78 @@ mod test { } } + #[tokio::test] + async fn test_handle_get_sonarr_blocklist_event_no_op_when_user_is_selecting_sort_options() { + 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 (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; + app_arc + .lock() + .await + .push_navigation_stack(ActiveSonarrBlock::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_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!(app_arc.lock().await.data.sonarr_data.blocklist.is_empty()); + assert!(app_arc.lock().await.data.sonarr_data.blocklist.sort_asc); + assert_eq!(blocklist, response); + } + } + #[tokio::test] async fn test_handle_get_sonarr_downloads_event() { let downloads_response_json = json!({ @@ -710,6 +785,246 @@ mod test { } } + #[rstest] + #[tokio::test] + async fn test_handle_get_sonarr_history_event(#[values(true, false)] use_custom_sorting: bool) { + let history_json = json!({"records": [{ + "id": 123, + "sourceTitle": "z episode", + "episodeId": 1007, + "quality": { "quality": { "name": "Bluray-1080p" } }, + "languages": [{"name": "English"}], + "date": "2024-02-10T07:28:45Z", + "eventType": "grabbed", + "data": { + "droppedPath": "/nfs/nzbget/completed/series/Coolness/something.cool.mkv", + "importedPath": "/nfs/tv/Coolness/Season 1/Coolness - S01E01 - Something Cool Bluray-1080p.mkv" + } + }, + { + "id": 456, + "sourceTitle": "A Episode", + "episodeId": 2001, + "quality": { "quality": { "name": "Bluray-1080p" } }, + "languages": [{"name": "English"}], + "date": "2024-02-10T07:28:45Z", + "eventType": "grabbed", + "data": { + "droppedPath": "/nfs/nzbget/completed/series/Coolness/something.cool.mkv", + "importedPath": "/nfs/tv/Coolness/Season 1/Coolness - S01E01 - Something Cool Bluray-1080p.mkv" + } + }]}); + let response: SonarrHistoryWrapper = serde_json::from_value(history_json.clone()).unwrap(); + let mut expected_history_items = vec![ + SonarrHistoryItem { + id: 123, + episode_id: 1007, + source_title: "z episode".into(), + ..history_item() + }, + SonarrHistoryItem { + id: 456, + episode_id: 2001, + source_title: "A Episode".into(), + ..history_item() + }, + ]; + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(history_json), + None, + SonarrEvent::GetHistory(None), + None, + Some("pageSize=500&sortDirection=descending&sortKey=time"), + ) + .await; + app_arc.lock().await.data.sonarr_data.history.sort_asc = true; + if use_custom_sorting { + let cmp_fn = |a: &SonarrHistoryItem, b: &SonarrHistoryItem| { + a.source_title + .text + .to_lowercase() + .cmp(&b.source_title.text.to_lowercase()) + }; + expected_history_items.sort_by(cmp_fn); + + let history_sort_option = SortOption { + name: "Source Title", + cmp_fn: Some(cmp_fn), + }; + app_arc + .lock() + .await + .data + .sonarr_data + .history + .sorting(vec![history_sort_option]); + } + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + if let SonarrSerdeable::SonarrHistoryWrapper(history) = network + .handle_sonarr_event(SonarrEvent::GetHistory(None)) + .await + .unwrap() + { + async_server.assert_async().await; + assert_eq!( + app_arc.lock().await.data.sonarr_data.history.items, + expected_history_items + ); + assert!(app_arc.lock().await.data.sonarr_data.history.sort_asc); + assert_eq!(history, response); + } + } + + #[tokio::test] + async fn test_handle_get_sonarr_history_event_uses_provided_items() { + let history_json = json!({"records": [{ + "id": 123, + "sourceTitle": "z episode", + "episodeId": 1007, + "quality": { "quality": { "name": "Bluray-1080p" } }, + "languages": [{"name": "English"}], + "date": "2024-02-10T07:28:45Z", + "eventType": "grabbed", + "data": { + "droppedPath": "/nfs/nzbget/completed/series/Coolness/something.cool.mkv", + "importedPath": "/nfs/tv/Coolness/Season 1/Coolness - S01E01 - Something Cool Bluray-1080p.mkv" + } + }, + { + "id": 456, + "sourceTitle": "A Episode", + "episodeId": 2001, + "quality": { "quality": { "name": "Bluray-1080p" } }, + "languages": [{"name": "English"}], + "date": "2024-02-10T07:28:45Z", + "eventType": "grabbed", + "data": { + "droppedPath": "/nfs/nzbget/completed/series/Coolness/something.cool.mkv", + "importedPath": "/nfs/tv/Coolness/Season 1/Coolness - S01E01 - Something Cool Bluray-1080p.mkv" + } + }]}); + let response: SonarrHistoryWrapper = serde_json::from_value(history_json.clone()).unwrap(); + let expected_history_items = vec![ + SonarrHistoryItem { + id: 123, + episode_id: 1007, + source_title: "z episode".into(), + ..history_item() + }, + SonarrHistoryItem { + id: 456, + episode_id: 2001, + source_title: "A Episode".into(), + ..history_item() + }, + ]; + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(history_json), + None, + SonarrEvent::GetHistory(Some(1000)), + None, + Some("pageSize=1000&sortDirection=descending&sortKey=time"), + ) + .await; + app_arc.lock().await.data.sonarr_data.history.sort_asc = true; + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + if let SonarrSerdeable::SonarrHistoryWrapper(history) = network + .handle_sonarr_event(SonarrEvent::GetHistory(Some(1000))) + .await + .unwrap() + { + async_server.assert_async().await; + assert_eq!( + app_arc.lock().await.data.sonarr_data.history.items, + expected_history_items + ); + assert!(app_arc.lock().await.data.sonarr_data.history.sort_asc); + assert_eq!(history, response); + } + } + + #[tokio::test] + async fn test_handle_get_sonarr_history_event_no_op_when_user_is_selecting_sort_options() { + let history_json = json!({"records": [{ + "id": 123, + "sourceTitle": "z episode", + "episodeId": 1007, + "quality": { "quality": { "name": "Bluray-1080p" } }, + "languages": [{"name": "English"}], + "date": "2024-02-10T07:28:45Z", + "eventType": "grabbed", + "data": { + "droppedPath": "/nfs/nzbget/completed/series/Coolness/something.cool.mkv", + "importedPath": "/nfs/tv/Coolness/Season 1/Coolness - S01E01 - Something Cool Bluray-1080p.mkv" + } + }, + { + "id": 456, + "sourceTitle": "A Episode", + "episodeId": 2001, + "quality": { "quality": { "name": "Bluray-1080p" } }, + "languages": [{"name": "English"}], + "date": "2024-02-10T07:28:45Z", + "eventType": "grabbed", + "data": { + "droppedPath": "/nfs/nzbget/completed/series/Coolness/something.cool.mkv", + "importedPath": "/nfs/tv/Coolness/Season 1/Coolness - S01E01 - Something Cool Bluray-1080p.mkv" + } + }]}); + let response: SonarrHistoryWrapper = serde_json::from_value(history_json.clone()).unwrap(); + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(history_json), + None, + SonarrEvent::GetHistory(None), + None, + Some("pageSize=500&sortDirection=descending&sortKey=time"), + ) + .await; + app_arc.lock().await.data.sonarr_data.history.sort_asc = true; + app_arc + .lock() + .await + .push_navigation_stack(ActiveSonarrBlock::HistorySortPrompt.into()); + let cmp_fn = |a: &SonarrHistoryItem, b: &SonarrHistoryItem| { + a.source_title + .text + .to_lowercase() + .cmp(&b.source_title.text.to_lowercase()) + }; + + let history_sort_option = SortOption { + name: "Source Title", + cmp_fn: Some(cmp_fn), + }; + app_arc + .lock() + .await + .data + .sonarr_data + .history + .sorting(vec![history_sort_option]); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + if let SonarrSerdeable::SonarrHistoryWrapper(history) = network + .handle_sonarr_event(SonarrEvent::GetHistory(None)) + .await + .unwrap() + { + async_server.assert_async().await; + assert!(app_arc.lock().await.data.sonarr_data.history.is_empty()); + assert!(app_arc.lock().await.data.sonarr_data.history.sort_asc); + assert_eq!(history, response); + } + } + #[tokio::test] async fn test_handle_get_sonarr_indexers_event() { let indexers_response_json = json!([{ @@ -2326,6 +2641,27 @@ mod test { } } + fn history_data() -> SonarrHistoryData { + SonarrHistoryData { + dropped_path: "/nfs/nzbget/completed/series/Coolness/something.cool.mkv".to_owned(), + imported_path: + "/nfs/tv/Coolness/Season 1/Coolness - S01E01 - Something Cool Bluray-1080p.mkv".to_owned(), + } + } + + fn history_item() -> SonarrHistoryItem { + SonarrHistoryItem { + id: 1, + source_title: "Test source".into(), + episode_id: 1, + quality: quality_wrapper(), + languages: vec![language()], + date: DateTime::from(DateTime::parse_from_rfc3339("2024-02-10T07:28:45Z").unwrap()), + event_type: "grabbed".into(), + data: history_data(), + } + } + fn indexer() -> Indexer { Indexer { enable_rss: true, From 5872a6ba723e3cf0cd669eb54df66e33006c7c60 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Wed, 20 Nov 2024 14:13:20 -0700 Subject: [PATCH 033/119] feat(cli): Support for fetching all Sonarr history events --- src/cli/sonarr/list_command_handler.rs | 8 +++ src/cli/sonarr/list_command_handler_tests.rs | 51 +++++++++++++++++++- src/network/sonarr_network.rs | 10 ++-- 3 files changed, 62 insertions(+), 7 deletions(-) diff --git a/src/cli/sonarr/list_command_handler.rs b/src/cli/sonarr/list_command_handler.rs index a03c993..b4a265e 100644 --- a/src/cli/sonarr/list_command_handler.rs +++ b/src/cli/sonarr/list_command_handler.rs @@ -32,6 +32,11 @@ pub enum SonarrListCommand { )] series_id: i64, }, + #[command(about = "Fetch all Sonarr history events")] + History { + #[arg(long, help = "How many history events to fetch", default_value_t = 500)] + events: u64, + }, #[command(about = "List all Sonarr indexers")] Indexers, #[command(about = "Fetch Sonarr logs")] @@ -88,6 +93,9 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, SonarrListCommand> for SonarrListCommandH SonarrListCommand::Episodes { series_id } => { execute_network_event!(self, SonarrEvent::GetEpisodes(Some(series_id))); } + SonarrListCommand::History { events: items } => { + execute_network_event!(self, SonarrEvent::GetHistory(Some(items))); + } SonarrListCommand::Indexers => { execute_network_event!(self, SonarrEvent::GetIndexers); } diff --git a/src/cli/sonarr/list_command_handler_tests.rs b/src/cli/sonarr/list_command_handler_tests.rs index c5f48b8..cf7253d 100644 --- a/src/cli/sonarr/list_command_handler_tests.rs +++ b/src/cli/sonarr/list_command_handler_tests.rs @@ -50,6 +50,27 @@ mod tests { ); } + #[test] + fn test_list_history_events_flag_requires_arguments() { + let result = + Cli::command().try_get_matches_from(["managarr", "sonarr", "list", "history", "--events"]); + + assert!(result.is_err()); + assert_eq!(result.unwrap_err().kind(), ErrorKind::InvalidValue); + } + + #[test] + fn test_list_history_default_values() { + let expected_args = SonarrListCommand::History { events: 500 }; + let result = Cli::try_parse_from(["managarr", "sonarr", "list", "history"]); + + assert!(result.is_ok()); + + if let Some(Command::Sonarr(SonarrCommand::List(history_command))) = result.unwrap().command { + assert_eq!(history_command, expected_args); + } + } + #[test] fn test_list_logs_events_flag_requires_arguments() { let result = @@ -69,8 +90,8 @@ mod tests { assert!(result.is_ok()); - if let Some(Command::Sonarr(SonarrCommand::List(refresh_command))) = result.unwrap().command { - assert_eq!(refresh_command, expected_args); + if let Some(Command::Sonarr(SonarrCommand::List(logs_command))) = result.unwrap().command { + assert_eq!(logs_command, expected_args); } } @@ -165,6 +186,32 @@ mod tests { assert!(result.is_ok()); } + #[tokio::test] + async fn test_handle_list_history_command() { + let expected_events = 1000; + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::( + SonarrEvent::GetHistory(Some(expected_events)).into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Sonarr(SonarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::default())); + let list_history_command = SonarrListCommand::History { events: 1000 }; + + let result = + SonarrListCommandHandler::with(&app_arc, list_history_command, &mut mock_network) + .handle() + .await; + + assert!(result.is_ok()); + } + #[tokio::test] async fn test_handle_list_logs_command() { let expected_events = 1000; diff --git a/src/network/sonarr_network.rs b/src/network/sonarr_network.rs index b81e851..e7cd2e2 100644 --- a/src/network/sonarr_network.rs +++ b/src/network/sonarr_network.rs @@ -108,8 +108,8 @@ impl<'a, 'b> Network<'a, 'b> { .get_episode_details(episode_id) .await .map(SonarrSerdeable::from), - SonarrEvent::GetHistory(items) => self - .get_sonarr_history(items) + SonarrEvent::GetHistory(events) => self + .get_sonarr_history(events) .await .map(SonarrSerdeable::from), SonarrEvent::GetHostConfig => self @@ -467,13 +467,13 @@ impl<'a, 'b> Network<'a, 'b> { .await } - async fn get_sonarr_history(&mut self, items: Option) -> Result { + async fn get_sonarr_history(&mut self, events: Option) -> Result { info!("Fetching all Sonarr history events"); - let event = SonarrEvent::GetHistory(items); + let event = SonarrEvent::GetHistory(events); let params = format!( "pageSize={}&sortDirection=descending&sortKey=time", - items.unwrap_or(500) + events.unwrap_or(500) ); let request_props = self .request_props_from(event, RequestMethod::Get, None::<()>, None, Some(params)) From 86d93377ac1acdcecd56534fcbf58cd59278c135 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Wed, 20 Nov 2024 14:54:16 -0700 Subject: [PATCH 034/119] feat(network): Support for fetching Sonarr series history for a given series ID --- src/models/servarr_data/sonarr/sonarr_data.rs | 7 +- src/models/sonarr_models.rs | 2 + src/models/sonarr_models_tests.rs | 20 +- src/network/sonarr_network.rs | 57 ++- src/network/sonarr_network_tests.rs | 444 +++++++++++++++++- 5 files changed, 516 insertions(+), 14 deletions(-) diff --git a/src/models/servarr_data/sonarr/sonarr_data.rs b/src/models/servarr_data/sonarr/sonarr_data.rs index d60b52e..c3434ce 100644 --- a/src/models/servarr_data/sonarr/sonarr_data.rs +++ b/src/models/servarr_data/sonarr/sonarr_data.rs @@ -58,7 +58,6 @@ impl Default for SonarrData { #[derive(Clone, Copy, PartialEq, Eq, Debug, Default, EnumIter)] pub enum ActiveSonarrBlock { - #[default] AddRootFolderPrompt, AddSeriesAlreadyInLibrary, AddSeriesConfirmPrompt, @@ -95,6 +94,8 @@ pub enum ActiveSonarrBlock { FilterHistoryError, FilterSeries, FilterSeriesError, + FilterSeriesHistory, + FilterSeriesHistoryError, History, HistoryDetails, HistorySortPrompt, @@ -116,11 +117,15 @@ pub enum ActiveSonarrBlock { SearchSeasonError, SearchSeries, SearchSeriesError, + SearchSeriesHistory, + SearchSeriesHistoryError, SeasonDetails, SeasonHistory, + #[default] Series, SeriesDetails, SeriesHistory, + SeriesHistorySortPrompt, SeriesSortPrompt, System, SystemLogs, diff --git a/src/models/sonarr_models.rs b/src/models/sonarr_models.rs index 6b019ca..2fdae1a 100644 --- a/src/models/sonarr_models.rs +++ b/src/models/sonarr_models.rs @@ -349,6 +349,7 @@ pub enum SonarrSerdeable { SecurityConfig(SecurityConfig), SeriesVec(Vec), Series(Series), + SonarrHistoryItems(Vec), SonarrHistoryWrapper(SonarrHistoryWrapper), SystemStatus(SystemStatus), BlocklistResponse(BlocklistResponse), @@ -382,6 +383,7 @@ serde_enum_from!( SecurityConfig(SecurityConfig), SeriesVec(Vec), Series(Series), + SonarrHistoryItems(Vec), SonarrHistoryWrapper(SonarrHistoryWrapper), SystemStatus(SystemStatus), BlocklistResponse(BlocklistResponse), diff --git a/src/models/sonarr_models_tests.rs b/src/models/sonarr_models_tests.rs index 19676e8..81a2276 100644 --- a/src/models/sonarr_models_tests.rs +++ b/src/models/sonarr_models_tests.rs @@ -9,8 +9,8 @@ mod tests { }, sonarr_models::{ BlocklistItem, BlocklistResponse, DownloadRecord, DownloadsResponse, Episode, - IndexerSettings, Series, SeriesStatus, SeriesType, SonarrHistoryItem, SonarrHistoryWrapper, - SonarrSerdeable, SystemStatus, + IndexerSettings, Series, SeriesStatus, SeriesType, SonarrHistoryItem, SonarrSerdeable, + SystemStatus, }, Serdeable, }; @@ -169,18 +169,16 @@ mod tests { } #[test] - fn test_sonarr_serdeable_from_sonarr_history_wrapper() { - let history_wrapper = SonarrHistoryWrapper { - records: vec![SonarrHistoryItem { - id: 1, - ..SonarrHistoryItem::default() - }], - }; - let sonarr_serdeable: SonarrSerdeable = history_wrapper.clone().into(); + fn test_sonarr_serdeable_from_sonarr_history_items() { + let history_items = vec![SonarrHistoryItem { + id: 1, + ..SonarrHistoryItem::default() + }]; + let sonarr_serdeable: SonarrSerdeable = history_items.clone().into(); assert_eq!( sonarr_serdeable, - SonarrSerdeable::SonarrHistoryWrapper(history_wrapper) + SonarrSerdeable::SonarrHistoryItems(history_items) ); } diff --git a/src/network/sonarr_network.rs b/src/network/sonarr_network.rs index e7cd2e2..cc8325e 100644 --- a/src/network/sonarr_network.rs +++ b/src/network/sonarr_network.rs @@ -14,8 +14,9 @@ use crate::{ }, sonarr_models::{ BlocklistResponse, DownloadRecord, DownloadsResponse, Episode, IndexerSettings, Series, - SonarrHistoryWrapper, SonarrSerdeable, SystemStatus, + SonarrHistoryItem, SonarrHistoryWrapper, SonarrSerdeable, SystemStatus, }, + stateful_table::StatefulTable, HorizontallyScrollableText, Route, Scrollable, ScrollableText, }, network::RequestMethod, @@ -46,6 +47,7 @@ pub enum SonarrEvent { GetSeasonReleases(Option<(i64, i64)>), GetSecurityConfig, GetSeriesDetails(Option), + GetSeriesHistory(Option), GetStatus, HealthCheck, ListSeries, @@ -67,6 +69,7 @@ impl NetworkResource for SonarrEvent { SonarrEvent::GetQualityProfiles => "/qualityprofile", SonarrEvent::GetQueuedEvents => "/command", SonarrEvent::GetSeasonReleases(_) | SonarrEvent::GetEpisodeReleases(_) => "/release", + SonarrEvent::GetSeriesHistory(_) => "/history/series", SonarrEvent::GetStatus => "/system/status", SonarrEvent::HealthCheck => "/health", SonarrEvent::ListSeries | SonarrEvent::GetSeriesDetails(_) => "/series", @@ -145,6 +148,10 @@ impl<'a, 'b> Network<'a, 'b> { .get_series_details(series_id) .await .map(SonarrSerdeable::from), + SonarrEvent::GetSeriesHistory(series_id) => self + .get_sonarr_series_history(series_id) + .await + .map(SonarrSerdeable::from), SonarrEvent::GetStatus => self.get_sonarr_status().await.map(SonarrSerdeable::from), SonarrEvent::HealthCheck => self .get_sonarr_healthcheck() @@ -726,6 +733,54 @@ impl<'a, 'b> Network<'a, 'b> { .await } + async fn get_sonarr_series_history( + &mut self, + series_id: Option, + ) -> Result> { + let (id, series_id_param) = self.extract_series_id(series_id).await; + info!("Fetching Sonarr series history for series with ID: {id}"); + let event = SonarrEvent::GetSeriesHistory(series_id); + + let request_props = self + .request_props_from( + event, + RequestMethod::Get, + None::<()>, + None, + Some(series_id_param), + ) + .await; + + self + .handle_request::<(), Vec>(request_props, |mut history_vec, mut app| { + if app.data.sonarr_data.series_history.is_none() { + app.data.sonarr_data.series_history = Some(StatefulTable::default()); + } + + if !matches!( + app.get_current_route(), + Route::Sonarr(ActiveSonarrBlock::SeriesHistorySortPrompt, _) + ) { + history_vec.sort_by(|a, b| a.id.cmp(&b.id)); + app + .data + .sonarr_data + .series_history + .as_mut() + .unwrap() + .set_items(history_vec); + app + .data + .sonarr_data + .series_history + .as_mut() + .unwrap() + .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 50adb81..bd5ad47 100644 --- a/src/network/sonarr_network_tests.rs +++ b/src/network/sonarr_network_tests.rs @@ -155,6 +155,11 @@ mod test { assert_str_eq!(event.resource(), "/indexer"); } + #[rstest] + fn test_resource_history(#[values(SonarrEvent::GetSeriesHistory(None))] event: SonarrEvent) { + assert_str_eq!(event.resource(), "/history/series"); + } + #[rstest] fn test_resource_release( #[values( @@ -999,7 +1004,6 @@ mod test { .to_lowercase() .cmp(&b.source_title.text.to_lowercase()) }; - let history_sort_option = SortOption { name: "Source Title", cmp_fn: Some(cmp_fn), @@ -2211,6 +2215,444 @@ mod test { } } + #[rstest] + #[tokio::test] + async fn test_handle_get_sonarr_series_history_event( + #[values(true, false)] use_custom_sorting: bool, + ) { + let history_json = json!([{ + "id": 123, + "sourceTitle": "z episode", + "episodeId": 1007, + "quality": { "quality": { "name": "Bluray-1080p" } }, + "languages": [{"name": "English"}], + "date": "2024-02-10T07:28:45Z", + "eventType": "grabbed", + "data": { + "droppedPath": "/nfs/nzbget/completed/series/Coolness/something.cool.mkv", + "importedPath": "/nfs/tv/Coolness/Season 1/Coolness - S01E01 - Something Cool Bluray-1080p.mkv" + } + }, + { + "id": 456, + "sourceTitle": "A Episode", + "episodeId": 2001, + "quality": { "quality": { "name": "Bluray-1080p" } }, + "languages": [{"name": "English"}], + "date": "2024-02-10T07:28:45Z", + "eventType": "grabbed", + "data": { + "droppedPath": "/nfs/nzbget/completed/series/Coolness/something.cool.mkv", + "importedPath": "/nfs/tv/Coolness/Season 1/Coolness - S01E01 - Something Cool Bluray-1080p.mkv" + } + }]); + let response: Vec = serde_json::from_value(history_json.clone()).unwrap(); + let mut expected_history_items = vec![ + SonarrHistoryItem { + id: 123, + episode_id: 1007, + source_title: "z episode".into(), + ..history_item() + }, + SonarrHistoryItem { + id: 456, + episode_id: 2001, + source_title: "A Episode".into(), + ..history_item() + }, + ]; + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(history_json), + None, + SonarrEvent::GetSeriesHistory(None), + None, + Some("seriesId=1"), + ) + .await; + let mut series_history_table = StatefulTable { + sort_asc: true, + ..StatefulTable::default() + }; + if use_custom_sorting { + let cmp_fn = |a: &SonarrHistoryItem, b: &SonarrHistoryItem| { + a.source_title + .text + .to_lowercase() + .cmp(&b.source_title.text.to_lowercase()) + }; + expected_history_items.sort_by(cmp_fn); + + let history_sort_option = SortOption { + name: "Source Title", + cmp_fn: Some(cmp_fn), + }; + series_history_table.sorting(vec![history_sort_option]); + } + app_arc + .lock() + .await + .data + .sonarr_data + .series + .set_items(vec![series()]); + app_arc.lock().await.data.sonarr_data.series_history = Some(series_history_table); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + if let SonarrSerdeable::SonarrHistoryItems(history_items) = network + .handle_sonarr_event(SonarrEvent::GetSeriesHistory(None)) + .await + .unwrap() + { + async_server.assert_async().await; + assert!(app_arc + .lock() + .await + .data + .sonarr_data + .series_history + .is_some()); + assert_eq!( + app_arc + .lock() + .await + .data + .sonarr_data + .series_history + .as_ref() + .unwrap() + .items, + expected_history_items + ); + assert!( + app_arc + .lock() + .await + .data + .sonarr_data + .series_history + .as_ref() + .unwrap() + .sort_asc + ); + assert_eq!(history_items, response); + } + } + + #[tokio::test] + async fn test_handle_get_sonarr_series_history_event_uses_provided_series_id() { + let history_json = json!([{ + "id": 123, + "sourceTitle": "z episode", + "episodeId": 1007, + "quality": { "quality": { "name": "Bluray-1080p" } }, + "languages": [{"name": "English"}], + "date": "2024-02-10T07:28:45Z", + "eventType": "grabbed", + "data": { + "droppedPath": "/nfs/nzbget/completed/series/Coolness/something.cool.mkv", + "importedPath": "/nfs/tv/Coolness/Season 1/Coolness - S01E01 - Something Cool Bluray-1080p.mkv" + } + }, + { + "id": 456, + "sourceTitle": "A Episode", + "episodeId": 2001, + "quality": { "quality": { "name": "Bluray-1080p" } }, + "languages": [{"name": "English"}], + "date": "2024-02-10T07:28:45Z", + "eventType": "grabbed", + "data": { + "droppedPath": "/nfs/nzbget/completed/series/Coolness/something.cool.mkv", + "importedPath": "/nfs/tv/Coolness/Season 1/Coolness - S01E01 - Something Cool Bluray-1080p.mkv" + } + }]); + let response: Vec = serde_json::from_value(history_json.clone()).unwrap(); + let expected_history_items = vec![ + SonarrHistoryItem { + id: 123, + episode_id: 1007, + source_title: "z episode".into(), + ..history_item() + }, + SonarrHistoryItem { + id: 456, + episode_id: 2001, + source_title: "A Episode".into(), + ..history_item() + }, + ]; + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(history_json), + None, + SonarrEvent::GetSeriesHistory(Some(2)), + None, + Some("seriesId=2"), + ) + .await; + app_arc.lock().await.data.sonarr_data.series_history = Some(StatefulTable { + sort_asc: true, + ..StatefulTable::default() + }); + app_arc + .lock() + .await + .data + .sonarr_data + .series + .set_items(vec![series()]); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + if let SonarrSerdeable::SonarrHistoryItems(history_items) = network + .handle_sonarr_event(SonarrEvent::GetSeriesHistory(Some(2))) + .await + .unwrap() + { + async_server.assert_async().await; + assert!(app_arc + .lock() + .await + .data + .sonarr_data + .series_history + .is_some()); + assert_eq!( + app_arc + .lock() + .await + .data + .sonarr_data + .series_history + .as_ref() + .unwrap() + .items, + expected_history_items + ); + assert!( + app_arc + .lock() + .await + .data + .sonarr_data + .series_history + .as_ref() + .unwrap() + .sort_asc + ); + assert_eq!(history_items, response); + } + } + + #[tokio::test] + async fn test_handle_get_sonarr_series_history_event_empty_series_history_table() { + let history_json = json!([{ + "id": 123, + "sourceTitle": "z episode", + "episodeId": 1007, + "quality": { "quality": { "name": "Bluray-1080p" } }, + "languages": [{"name": "English"}], + "date": "2024-02-10T07:28:45Z", + "eventType": "grabbed", + "data": { + "droppedPath": "/nfs/nzbget/completed/series/Coolness/something.cool.mkv", + "importedPath": "/nfs/tv/Coolness/Season 1/Coolness - S01E01 - Something Cool Bluray-1080p.mkv" + } + }, + { + "id": 456, + "sourceTitle": "A Episode", + "episodeId": 2001, + "quality": { "quality": { "name": "Bluray-1080p" } }, + "languages": [{"name": "English"}], + "date": "2024-02-10T07:28:45Z", + "eventType": "grabbed", + "data": { + "droppedPath": "/nfs/nzbget/completed/series/Coolness/something.cool.mkv", + "importedPath": "/nfs/tv/Coolness/Season 1/Coolness - S01E01 - Something Cool Bluray-1080p.mkv" + } + }]); + let response: Vec = serde_json::from_value(history_json.clone()).unwrap(); + let expected_history_items = vec![ + SonarrHistoryItem { + id: 123, + episode_id: 1007, + source_title: "z episode".into(), + ..history_item() + }, + SonarrHistoryItem { + id: 456, + episode_id: 2001, + source_title: "A Episode".into(), + ..history_item() + }, + ]; + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(history_json), + None, + SonarrEvent::GetSeriesHistory(None), + None, + Some("seriesId=1"), + ) + .await; + app_arc + .lock() + .await + .data + .sonarr_data + .series + .set_items(vec![series()]); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + if let SonarrSerdeable::SonarrHistoryItems(history_items) = network + .handle_sonarr_event(SonarrEvent::GetSeriesHistory(None)) + .await + .unwrap() + { + async_server.assert_async().await; + assert!(app_arc + .lock() + .await + .data + .sonarr_data + .series_history + .is_some()); + assert_eq!( + app_arc + .lock() + .await + .data + .sonarr_data + .series_history + .as_ref() + .unwrap() + .items, + expected_history_items + ); + assert!( + !app_arc + .lock() + .await + .data + .sonarr_data + .series_history + .as_ref() + .unwrap() + .sort_asc + ); + assert_eq!(history_items, response); + } + } + + #[tokio::test] + async fn test_handle_get_sonarr_series_history_event_no_op_when_user_is_selecting_sort_options() { + let history_json = json!([{ + "id": 123, + "sourceTitle": "z episode", + "episodeId": 1007, + "quality": { "quality": { "name": "Bluray-1080p" } }, + "languages": [{"name": "English"}], + "date": "2024-02-10T07:28:45Z", + "eventType": "grabbed", + "data": { + "droppedPath": "/nfs/nzbget/completed/series/Coolness/something.cool.mkv", + "importedPath": "/nfs/tv/Coolness/Season 1/Coolness - S01E01 - Something Cool Bluray-1080p.mkv" + } + }, + { + "id": 456, + "sourceTitle": "A Episode", + "episodeId": 2001, + "quality": { "quality": { "name": "Bluray-1080p" } }, + "languages": [{"name": "English"}], + "date": "2024-02-10T07:28:45Z", + "eventType": "grabbed", + "data": { + "droppedPath": "/nfs/nzbget/completed/series/Coolness/something.cool.mkv", + "importedPath": "/nfs/tv/Coolness/Season 1/Coolness - S01E01 - Something Cool Bluray-1080p.mkv" + } + }]); + let response: Vec = serde_json::from_value(history_json.clone()).unwrap(); + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(history_json), + None, + SonarrEvent::GetSeriesHistory(None), + None, + Some("seriesId=1"), + ) + .await; + let cmp_fn = |a: &SonarrHistoryItem, b: &SonarrHistoryItem| { + a.source_title + .text + .to_lowercase() + .cmp(&b.source_title.text.to_lowercase()) + }; + let history_sort_option = SortOption { + name: "Source Title", + cmp_fn: Some(cmp_fn), + }; + let mut series_history_table = StatefulTable { + sort_asc: true, + ..StatefulTable::default() + }; + series_history_table.sorting(vec![history_sort_option]); + app_arc.lock().await.data.sonarr_data.series_history = Some(series_history_table); + app_arc + .lock() + .await + .data + .sonarr_data + .series + .set_items(vec![series()]); + app_arc + .lock() + .await + .push_navigation_stack(ActiveSonarrBlock::SeriesHistorySortPrompt.into()); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + if let SonarrSerdeable::SonarrHistoryItems(history_items) = network + .handle_sonarr_event(SonarrEvent::GetSeriesHistory(None)) + .await + .unwrap() + { + async_server.assert_async().await; + assert!(app_arc + .lock() + .await + .data + .sonarr_data + .series_history + .is_some()); + assert!(app_arc + .lock() + .await + .data + .sonarr_data + .series_history + .as_ref() + .unwrap() + .is_empty()); + assert!( + app_arc + .lock() + .await + .data + .sonarr_data + .series_history + .as_ref() + .unwrap() + .sort_asc + ); + assert_eq!(history_items, response); + } + } + #[tokio::test] async fn test_handle_get_series_event_no_op_while_user_is_selecting_sort_options() { let mut series_1: Value = serde_json::from_str(SERIES_JSON).unwrap(); From df1eea22abbf3ba11adca38f69f7f6b7e0cb8739 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Wed, 20 Nov 2024 14:58:54 -0700 Subject: [PATCH 035/119] feat(cli): Support for fetching history for a given series ID --- src/cli/sonarr/list_command_handler.rs | 12 +++++ src/cli/sonarr/list_command_handler_tests.rs | 57 ++++++++++++++++++++ 2 files changed, 69 insertions(+) diff --git a/src/cli/sonarr/list_command_handler.rs b/src/cli/sonarr/list_command_handler.rs index b4a265e..b95ac8d 100644 --- a/src/cli/sonarr/list_command_handler.rs +++ b/src/cli/sonarr/list_command_handler.rs @@ -55,6 +55,15 @@ pub enum SonarrListCommand { QueuedEvents, #[command(about = "List all series in your Sonarr library")] Series, + #[command(about = "Fetch all history events for the series with the given ID")] + SeriesHistory { + #[arg( + long, + help = "The Sonarr ID of the series whose history you wish to fetch", + required = true + )] + series_id: i64, + }, } impl From for Command { @@ -127,6 +136,9 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, SonarrListCommand> for SonarrListCommandH SonarrListCommand::Series => { execute_network_event!(self, SonarrEvent::ListSeries); } + SonarrListCommand::SeriesHistory { series_id } => { + execute_network_event!(self, SonarrEvent::GetSeriesHistory(Some(series_id))); + } } Ok(()) diff --git a/src/cli/sonarr/list_command_handler_tests.rs b/src/cli/sonarr/list_command_handler_tests.rs index cf7253d..6d7d9c1 100644 --- a/src/cli/sonarr/list_command_handler_tests.rs +++ b/src/cli/sonarr/list_command_handler_tests.rs @@ -108,6 +108,37 @@ mod tests { assert_eq!(episodes_command, expected_args); } } + + #[test] + fn test_list_series_history_requires_series_id() { + let result = + Cli::command().try_get_matches_from(["managarr", "sonarr", "list", "series-history"]); + + assert!(result.is_err()); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_list_series_history_success() { + let expected_args = SonarrListCommand::SeriesHistory { series_id: 1 }; + let result = Cli::try_parse_from([ + "managarr", + "sonarr", + "list", + "series-history", + "--series-id", + "1", + ]); + + assert!(result.is_ok()); + + if let Some(Command::Sonarr(SonarrCommand::List(series_command))) = result.unwrap().command { + assert_eq!(series_command, expected_args); + } + } } mod handler { @@ -239,5 +270,31 @@ mod tests { assert!(result.is_ok()); } + + #[tokio::test] + async fn test_handle_list_series_history_command() { + let expected_series_id = 1; + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::( + SonarrEvent::GetSeriesHistory(Some(expected_series_id)).into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Sonarr(SonarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::default())); + let list_series_history_command = SonarrListCommand::SeriesHistory { series_id: 1 }; + + let result = + SonarrListCommandHandler::with(&app_arc, list_series_history_command, &mut mock_network) + .handle() + .await; + + assert!(result.is_ok()); + } } } From f5631376af722f866d50c138d060a5016876c32f Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Wed, 20 Nov 2024 19:22:13 -0700 Subject: [PATCH 036/119] fix(network): Fixed an issue with dynamic typing in responses from Sonarr for history items --- src/models/mod.rs | 4 ++ src/models/radarr_models.rs | 10 ++-- src/models/radarr_models_tests.rs | 2 +- src/models/servarr_data/sonarr/modals.rs | 7 ++- src/network/sonarr_network.rs | 2 +- src/network/sonarr_network_tests.rs | 56 ++++++++++--------- .../collections/collection_details_ui.rs | 2 +- .../collections/edit_collection_ui.rs | 2 +- src/ui/radarr_ui/library/add_movie_ui.rs | 2 +- src/ui/radarr_ui/library/edit_movie_ui.rs | 2 +- 10 files changed, 49 insertions(+), 40 deletions(-) diff --git a/src/models/mod.rs b/src/models/mod.rs index 5541424..d0aa2c9 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -41,6 +41,10 @@ pub enum Serdeable { Sonarr(SonarrSerdeable), } +pub trait EnumDisplayStyle<'a> { + fn to_display_str(self) -> &'a str; +} + pub trait Scrollable { fn scroll_down(&mut self); fn scroll_up(&mut self); diff --git a/src/models/radarr_models.rs b/src/models/radarr_models.rs index 89f45b8..286f3ee 100644 --- a/src/models/radarr_models.rs +++ b/src/models/radarr_models.rs @@ -13,7 +13,7 @@ use super::servarr_models::{ HostConfig, Indexer, Language, LogResponse, QualityProfile, QualityWrapper, QueueEvent, Release, SecurityConfig, }; -use super::Serdeable; +use super::{EnumDisplayStyle, Serdeable}; #[cfg(test)] #[path = "radarr_models_tests.rs"] @@ -313,8 +313,8 @@ impl Display for MinimumAvailability { } } -impl MinimumAvailability { - pub fn to_display_str<'a>(self) -> &'a str { +impl<'a> EnumDisplayStyle<'a> for MinimumAvailability { + fn to_display_str(self) -> &'a str { match self { MinimumAvailability::Tba => "TBA", MinimumAvailability::Announced => "Announced", @@ -343,8 +343,8 @@ impl Display for Monitor { } } -impl Monitor { - pub fn to_display_str<'a>(self) -> &'a str { +impl<'a> EnumDisplayStyle<'a> for Monitor { + fn to_display_str(self) -> &'a str { match self { Monitor::MovieOnly => "Movie only", Monitor::MovieAndCollection => "Movie and Collection", diff --git a/src/models/radarr_models_tests.rs b/src/models/radarr_models_tests.rs index 3ef03b7..8cb1643 100644 --- a/src/models/radarr_models_tests.rs +++ b/src/models/radarr_models_tests.rs @@ -11,7 +11,7 @@ mod tests { Release, RootFolder, SystemStatus, Tag, Task, TaskName, Update, }, servarr_models::{HostConfig, Log, LogResponse, QueueEvent, SecurityConfig}, - Serdeable, + EnumDisplayStyle, Serdeable, }; #[test] diff --git a/src/models/servarr_data/sonarr/modals.rs b/src/models/servarr_data/sonarr/modals.rs index 4745db9..71dbb96 100644 --- a/src/models/servarr_data/sonarr/modals.rs +++ b/src/models/servarr_data/sonarr/modals.rs @@ -1,5 +1,8 @@ use crate::models::{ - servarr_models::Release, sonarr_models::Episode, stateful_table::StatefulTable, ScrollableText, + servarr_models::Release, + sonarr_models::{Episode, SonarrHistoryItem}, + stateful_table::StatefulTable, + ScrollableText, }; #[derive(Default)] @@ -8,7 +11,7 @@ pub struct EpisodeDetailsModal { pub file_details: String, pub audio_details: String, pub video_details: String, - // pub episode_history: StatefulTable, + pub episode_history: StatefulTable, pub episode_releases: StatefulTable, } diff --git a/src/network/sonarr_network.rs b/src/network/sonarr_network.rs index cc8325e..4eb89c8 100644 --- a/src/network/sonarr_network.rs +++ b/src/network/sonarr_network.rs @@ -479,7 +479,7 @@ impl<'a, 'b> Network<'a, 'b> { let event = SonarrEvent::GetHistory(events); let params = format!( - "pageSize={}&sortDirection=descending&sortKey=time", + "pageSize={}&sortDirection=descending&sortKey=date", events.unwrap_or(500) ); let request_props = self diff --git a/src/network/sonarr_network_tests.rs b/src/network/sonarr_network_tests.rs index bd5ad47..050ea84 100644 --- a/src/network/sonarr_network_tests.rs +++ b/src/network/sonarr_network_tests.rs @@ -156,8 +156,8 @@ mod test { } #[rstest] - fn test_resource_history(#[values(SonarrEvent::GetSeriesHistory(None))] event: SonarrEvent) { - assert_str_eq!(event.resource(), "/history/series"); + fn test_resource_history(#[values(SonarrEvent::GetHistory(None))] event: SonarrEvent) { + assert_str_eq!(event.resource(), "/history"); } #[rstest] @@ -177,7 +177,7 @@ mod test { #[case(SonarrEvent::HealthCheck, "/health")] #[case(SonarrEvent::GetBlocklist, "/blocklist?page=1&pageSize=10000")] #[case(SonarrEvent::GetDownloads, "/queue")] - #[case(SonarrEvent::GetHistory(None), "/history")] + #[case(SonarrEvent::GetSeriesHistory(None), "/history/series")] #[case(SonarrEvent::GetLogs(Some(500)), "/log")] #[case(SonarrEvent::GetQualityProfiles, "/qualityprofile")] #[case(SonarrEvent::GetStatus, "/system/status")] @@ -438,8 +438,8 @@ mod test { "status": "downloading", "id": 1, "episodeId": 1, - "size": 3543348019u64, - "sizeleft": 1771674009, + "size": 3543348019f64, + "sizeleft": 1771674009f64, "outputPath": "/nfs/tv/Test show/season 1/", "indexer": "kickass torrents", "downloadClient": "transmission", @@ -798,7 +798,7 @@ mod test { "sourceTitle": "z episode", "episodeId": 1007, "quality": { "quality": { "name": "Bluray-1080p" } }, - "languages": [{"name": "English"}], + "language": { "name": "English" }, "date": "2024-02-10T07:28:45Z", "eventType": "grabbed", "data": { @@ -811,7 +811,7 @@ mod test { "sourceTitle": "A Episode", "episodeId": 2001, "quality": { "quality": { "name": "Bluray-1080p" } }, - "languages": [{"name": "English"}], + "language": { "name": "English" }, "date": "2024-02-10T07:28:45Z", "eventType": "grabbed", "data": { @@ -841,7 +841,7 @@ mod test { None, SonarrEvent::GetHistory(None), None, - Some("pageSize=500&sortDirection=descending&sortKey=time"), + Some("pageSize=500&sortDirection=descending&sortKey=date"), ) .await; app_arc.lock().await.data.sonarr_data.history.sort_asc = true; @@ -890,7 +890,7 @@ mod test { "sourceTitle": "z episode", "episodeId": 1007, "quality": { "quality": { "name": "Bluray-1080p" } }, - "languages": [{"name": "English"}], + "language": { "name": "English" }, "date": "2024-02-10T07:28:45Z", "eventType": "grabbed", "data": { @@ -903,7 +903,7 @@ mod test { "sourceTitle": "A Episode", "episodeId": 2001, "quality": { "quality": { "name": "Bluray-1080p" } }, - "languages": [{"name": "English"}], + "language": { "name": "English" }, "date": "2024-02-10T07:28:45Z", "eventType": "grabbed", "data": { @@ -933,7 +933,7 @@ mod test { None, SonarrEvent::GetHistory(Some(1000)), None, - Some("pageSize=1000&sortDirection=descending&sortKey=time"), + Some("pageSize=1000&sortDirection=descending&sortKey=date"), ) .await; app_arc.lock().await.data.sonarr_data.history.sort_asc = true; @@ -961,7 +961,7 @@ mod test { "sourceTitle": "z episode", "episodeId": 1007, "quality": { "quality": { "name": "Bluray-1080p" } }, - "languages": [{"name": "English"}], + "language": { "name": "English" }, "date": "2024-02-10T07:28:45Z", "eventType": "grabbed", "data": { @@ -974,7 +974,7 @@ mod test { "sourceTitle": "A Episode", "episodeId": 2001, "quality": { "quality": { "name": "Bluray-1080p" } }, - "languages": [{"name": "English"}], + "language": { "name": "English" }, "date": "2024-02-10T07:28:45Z", "eventType": "grabbed", "data": { @@ -990,7 +990,7 @@ mod test { None, SonarrEvent::GetHistory(None), None, - Some("pageSize=500&sortDirection=descending&sortKey=time"), + Some("pageSize=500&sortDirection=descending&sortKey=date"), ) .await; app_arc.lock().await.data.sonarr_data.history.sort_asc = true; @@ -2225,7 +2225,7 @@ mod test { "sourceTitle": "z episode", "episodeId": 1007, "quality": { "quality": { "name": "Bluray-1080p" } }, - "languages": [{"name": "English"}], + "language": { "name": "English" }, "date": "2024-02-10T07:28:45Z", "eventType": "grabbed", "data": { @@ -2238,7 +2238,7 @@ mod test { "sourceTitle": "A Episode", "episodeId": 2001, "quality": { "quality": { "name": "Bluray-1080p" } }, - "languages": [{"name": "English"}], + "language": { "name": "English" }, "date": "2024-02-10T07:28:45Z", "eventType": "grabbed", "data": { @@ -2347,7 +2347,7 @@ mod test { "sourceTitle": "z episode", "episodeId": 1007, "quality": { "quality": { "name": "Bluray-1080p" } }, - "languages": [{"name": "English"}], + "language": { "name": "English" }, "date": "2024-02-10T07:28:45Z", "eventType": "grabbed", "data": { @@ -2360,7 +2360,7 @@ mod test { "sourceTitle": "A Episode", "episodeId": 2001, "quality": { "quality": { "name": "Bluray-1080p" } }, - "languages": [{"name": "English"}], + "language": { "name": "English" }, "date": "2024-02-10T07:28:45Z", "eventType": "grabbed", "data": { @@ -2453,7 +2453,7 @@ mod test { "sourceTitle": "z episode", "episodeId": 1007, "quality": { "quality": { "name": "Bluray-1080p" } }, - "languages": [{"name": "English"}], + "language": { "name": "English" }, "date": "2024-02-10T07:28:45Z", "eventType": "grabbed", "data": { @@ -2466,7 +2466,7 @@ mod test { "sourceTitle": "A Episode", "episodeId": 2001, "quality": { "quality": { "name": "Bluray-1080p" } }, - "languages": [{"name": "English"}], + "language": { "name": "English" }, "date": "2024-02-10T07:28:45Z", "eventType": "grabbed", "data": { @@ -2555,7 +2555,7 @@ mod test { "sourceTitle": "z episode", "episodeId": 1007, "quality": { "quality": { "name": "Bluray-1080p" } }, - "languages": [{"name": "English"}], + "language": { "name": "English" }, "date": "2024-02-10T07:28:45Z", "eventType": "grabbed", "data": { @@ -2568,7 +2568,7 @@ mod test { "sourceTitle": "A Episode", "episodeId": 2001, "quality": { "quality": { "name": "Bluray-1080p" } }, - "languages": [{"name": "English"}], + "language": { "name": "English" }, "date": "2024-02-10T07:28:45Z", "eventType": "grabbed", "data": { @@ -3037,8 +3037,8 @@ mod test { status: "downloading".to_owned(), id: 1, episode_id: 1, - size: 3543348019, - sizeleft: 1771674009, + size: 3543348019f64, + sizeleft: 1771674009f64, output_path: Some(HorizontallyScrollableText::from( "/nfs/tv/Test show/season 1/", )), @@ -3085,9 +3085,11 @@ mod test { fn history_data() -> SonarrHistoryData { SonarrHistoryData { - dropped_path: "/nfs/nzbget/completed/series/Coolness/something.cool.mkv".to_owned(), - imported_path: + dropped_path: Some("/nfs/nzbget/completed/series/Coolness/something.cool.mkv".to_owned()), + imported_path: Some( "/nfs/tv/Coolness/Season 1/Coolness - S01E01 - Something Cool Bluray-1080p.mkv".to_owned(), + ), + ..SonarrHistoryData::default() } } @@ -3097,7 +3099,7 @@ mod test { source_title: "Test source".into(), episode_id: 1, quality: quality_wrapper(), - languages: vec![language()], + language: language(), date: DateTime::from(DateTime::parse_from_rfc3339("2024-02-10T07:28:45Z").unwrap()), event_type: "grabbed".into(), data: history_data(), diff --git a/src/ui/radarr_ui/collections/collection_details_ui.rs b/src/ui/radarr_ui/collections/collection_details_ui.rs index 25f19e4..917018c 100644 --- a/src/ui/radarr_ui/collections/collection_details_ui.rs +++ b/src/ui/radarr_ui/collections/collection_details_ui.rs @@ -11,7 +11,7 @@ use crate::models::radarr_models::CollectionMovie; use crate::models::servarr_data::radarr::radarr_data::{ ActiveRadarrBlock, COLLECTION_DETAILS_BLOCKS, }; -use crate::models::Route; +use crate::models::{EnumDisplayStyle, Route}; use crate::ui::radarr_ui::collections::draw_collections; use crate::ui::styles::ManagarrStyle; use crate::ui::utils::{ diff --git a/src/ui/radarr_ui/collections/edit_collection_ui.rs b/src/ui/radarr_ui/collections/edit_collection_ui.rs index c005f99..eac0943 100644 --- a/src/ui/radarr_ui/collections/edit_collection_ui.rs +++ b/src/ui/radarr_ui/collections/edit_collection_ui.rs @@ -12,7 +12,7 @@ use crate::models::servarr_data::radarr::modals::EditCollectionModal; use crate::models::servarr_data::radarr::radarr_data::{ ActiveRadarrBlock, COLLECTION_DETAILS_BLOCKS, EDIT_COLLECTION_BLOCKS, }; -use crate::models::Route; +use crate::models::{EnumDisplayStyle, Route}; use crate::render_selectable_input_box; use crate::ui::radarr_ui::collections::collection_details_ui::CollectionDetailsUi; use crate::ui::radarr_ui::collections::draw_collections; diff --git a/src/ui/radarr_ui/library/add_movie_ui.rs b/src/ui/radarr_ui/library/add_movie_ui.rs index 0008ac1..6903a6a 100644 --- a/src/ui/radarr_ui/library/add_movie_ui.rs +++ b/src/ui/radarr_ui/library/add_movie_ui.rs @@ -12,7 +12,7 @@ use crate::app::radarr::radarr_context_clues::{ use crate::models::radarr_models::AddMovieSearchResult; use crate::models::servarr_data::radarr::modals::AddMovieModal; use crate::models::servarr_data::radarr::radarr_data::{ActiveRadarrBlock, ADD_MOVIE_BLOCKS}; -use crate::models::Route; +use crate::models::{EnumDisplayStyle, Route}; use crate::ui::radarr_ui::collections::{draw_collection_details, draw_collections}; use crate::ui::radarr_ui::library::draw_library; use crate::ui::styles::ManagarrStyle; diff --git a/src/ui/radarr_ui/library/edit_movie_ui.rs b/src/ui/radarr_ui/library/edit_movie_ui.rs index 7930315..520bf2d 100644 --- a/src/ui/radarr_ui/library/edit_movie_ui.rs +++ b/src/ui/radarr_ui/library/edit_movie_ui.rs @@ -13,7 +13,7 @@ use crate::models::servarr_data::radarr::modals::EditMovieModal; use crate::models::servarr_data::radarr::radarr_data::{ ActiveRadarrBlock, EDIT_MOVIE_BLOCKS, MOVIE_DETAILS_BLOCKS, }; -use crate::models::Route; +use crate::models::{EnumDisplayStyle, Route}; use crate::render_selectable_input_box; use crate::ui::radarr_ui::library::draw_library; use crate::ui::radarr_ui::library::movie_details_ui::MovieDetailsUi; From 34157ef32f9a3e2a54731e8619328855b8066b8a Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Wed, 20 Nov 2024 19:33:40 -0700 Subject: [PATCH 037/119] feat(cli): Added a spinner to the CLI for long running commands like fetching releases --- Cargo.lock | 55 +++++++++ Cargo.toml | 1 + README.md | 8 +- src/cli/mod.rs | 25 +--- src/cli/radarr/add_command_handler.rs | 30 +++-- src/cli/radarr/delete_command_handler.rs | 48 +++++--- src/cli/radarr/edit_command_handler.rs | 51 ++++---- src/cli/radarr/get_command_handler.rs | 45 +++++-- src/cli/radarr/list_command_handler.rs | 87 ++++++++++---- src/cli/radarr/mod.rs | 57 +++++++-- src/cli/radarr/refresh_command_handler.rs | 33 ++++-- src/cli/sonarr/delete_command_handler.rs | 18 +-- src/cli/sonarr/get_command_handler.rs | 45 +++++-- src/cli/sonarr/list_command_handler.rs | 69 ++++++++--- src/cli/sonarr/mod.rs | 32 +++-- src/main.rs | 99 ++++------------ src/models/sonarr_models.rs | 82 +++++++++++-- src/models/sonarr_models_tests.rs | 66 ++++++++++- src/utils.rs | 137 +++++++++++++++++++++- 19 files changed, 717 insertions(+), 271 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 40c816b..ff134d6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -370,6 +370,19 @@ dependencies = [ "thiserror", ] +[[package]] +name = "console" +version = "0.15.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e1f83fc076bd6dd27517eacdf25fef6c4dfe5f1d7448bafaaf3a26f13b5e4eb" +dependencies = [ + "encode_unicode", + "lazy_static", + "libc", + "unicode-width 0.1.14", + "windows-sys 0.52.0", +] + [[package]] name = "core-foundation" version = "0.9.4" @@ -565,6 +578,12 @@ version = "1.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "60b1af1c220855b6ceac025d3f6ecdd2b7c4894bfe9cd9bda4fbb4bc7c0d4cf0" +[[package]] +name = "encode_unicode" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a357d28ed41a50f9c765dbfe56cbc04a64e53e5fc58ba79fbc34c10ef3df831f" + [[package]] name = "encoding_rs" version = "0.8.35" @@ -1117,6 +1136,19 @@ dependencies = [ "hashbrown", ] +[[package]] +name = "indicatif" +version = "0.17.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbf675b85ed934d3c67b5c5469701eec7db22689d0a2139d856e0925fa28b281" +dependencies = [ + "console", + "number_prefix", + "portable-atomic", + "unicode-width 0.2.0", + "web-time", +] + [[package]] name = "indoc" version = "2.0.5" @@ -1289,6 +1321,7 @@ dependencies = [ "derivative", "dirs-next", "human-panic", + "indicatif", "indoc", "itertools", "log", @@ -1458,6 +1491,12 @@ dependencies = [ "libc", ] +[[package]] +name = "number_prefix" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "830b246a0e5f20af87141b25c173cd1b609bd7779a4617d6ec582abaf90870f3" + [[package]] name = "object" version = "0.36.5" @@ -1596,6 +1635,12 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "953ec861398dccce10c670dfeaf3ec4911ca479e9c02154b3a215178c5f566f2" +[[package]] +name = "portable-atomic" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc9c68a3f6da06753e9335d63e27f6b9754dd1920d941135b7ea8224f141adb2" + [[package]] name = "powerfmt" version = "0.2.0" @@ -2675,6 +2720,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "winapi" version = "0.3.9" diff --git a/Cargo.toml b/Cargo.toml index 3563627..7c48c4d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -46,6 +46,7 @@ colored = "2.1.0" async-trait = "0.1.83" dirs-next = "2.0.0" managarr-tree-widget = "0.24.0" +indicatif = "0.17.9" [dev-dependencies] assert_cmd = "2.0.16" diff --git a/README.md b/README.md index aa47fba..597f2b0 100644 --- a/README.md +++ b/README.md @@ -236,9 +236,11 @@ tautulli: ## Environment Variables Managarr supports using environment variables on startup so you don't have to always specify certain flags: -| Variable | Description | Equivalent Flag | -| --------------------------------------- | -------------------------------- | -------------------------------- | -| `MANAGARR_CONFIG_FILE` | Set the path to the config file | `--config` | +| Variable | Description | Equivalent Flag | +|-----------------------------------------|--------------------------------------------------------------------------------|----------------------------------| +| `MANAGARR_CONFIG_FILE` | Set the path to the config file | `--config` | +| `MANAGARR_DISABLE_SPINNER` | Disable the CLI spinner (this can be useful when scripting and parsing output) | `--disable-spinner` | +|-----------------------------------------|--------------------------------------------------------------------------------|----------------------------------| ## Track My Progress for the Beta release (With Sonarr Support!) Progress for the beta release can be followed on my [Wekan Board](https://wekan.alexjclarke.com/b/dHoGjBb44MHM9HSv4/managarr) diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 1178276..5eb9c37 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -42,15 +42,15 @@ pub enum Command { pub trait CliCommandHandler<'a, 'b, T: Into> { fn with(app: &'a Arc>>, command: T, network: &'a mut dyn NetworkTrait) -> Self; - async fn handle(self) -> Result<()>; + async fn handle(self) -> Result; } pub(crate) async fn handle_command( app: &Arc>>, command: Command, network: &mut dyn NetworkTrait, -) -> Result<()> { - match command { +) -> Result { + let result = match command { Command::Radarr(radarr_command) => { RadarrCliHandler::with(app, radarr_command, network) .handle() @@ -61,10 +61,10 @@ pub(crate) async fn handle_command( .handle() .await? } - _ => (), - } + _ => String::new(), + }; - Ok(()) + Ok(result) } #[inline] @@ -88,16 +88,3 @@ pub fn mutex_flags_or_default(positive: bool, negative: bool, default_value: boo default_value } } - -#[macro_export] -macro_rules! execute_network_event { - ($self:ident, $event:expr) => { - let resp = $self.network.handle_network_event($event.into()).await?; - let json = serde_json::to_string_pretty(&resp)?; - println!("{}", json); - }; - ($self:ident, $event:expr, $happy_output:expr) => { - $self.network.handle_network_event($event.into()).await?; - println!("{}", $happy_output); - }; -} diff --git a/src/cli/radarr/add_command_handler.rs b/src/cli/radarr/add_command_handler.rs index 1e26963..007306f 100644 --- a/src/cli/radarr/add_command_handler.rs +++ b/src/cli/radarr/add_command_handler.rs @@ -7,7 +7,6 @@ use tokio::sync::Mutex; use crate::{ app::App, cli::{CliCommandHandler, Command}, - execute_network_event, models::radarr_models::{AddMovieBody, AddOptions, MinimumAvailability, Monitor}, network::{radarr_network::RadarrEvent, NetworkTrait}, }; @@ -106,8 +105,8 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, RadarrAddCommand> for RadarrAddCommandHan } } - async fn handle(self) -> Result<()> { - match self.command { + async fn handle(self) -> Result { + let result = match self.command { RadarrAddCommand::Movie { tmdb_id, root_folder_path, @@ -131,19 +130,28 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, RadarrAddCommand> for RadarrAddCommandHan search_for_movie: !no_search_for_movie, }, }; - execute_network_event!(self, RadarrEvent::AddMovie(Some(body))); + let resp = self + .network + .handle_network_event((RadarrEvent::AddMovie(Some(body))).into()) + .await?; + serde_json::to_string_pretty(&resp)? } RadarrAddCommand::RootFolder { root_folder_path } => { - execute_network_event!( - self, - RadarrEvent::AddRootFolder(Some(root_folder_path.clone())) - ); + let resp = self + .network + .handle_network_event((RadarrEvent::AddRootFolder(Some(root_folder_path.clone()))).into()) + .await?; + serde_json::to_string_pretty(&resp)? } RadarrAddCommand::Tag { name } => { - execute_network_event!(self, RadarrEvent::AddTag(name.clone())); + let resp = self + .network + .handle_network_event((RadarrEvent::AddTag(name.clone())).into()) + .await?; + serde_json::to_string_pretty(&resp)? } - } + }; - Ok(()) + Ok(result) } } diff --git a/src/cli/radarr/delete_command_handler.rs b/src/cli/radarr/delete_command_handler.rs index db26775..8568c88 100644 --- a/src/cli/radarr/delete_command_handler.rs +++ b/src/cli/radarr/delete_command_handler.rs @@ -7,7 +7,6 @@ use tokio::sync::Mutex; use crate::{ app::App, cli::{CliCommandHandler, Command}, - execute_network_event, models::radarr_models::DeleteMovieParams, network::{radarr_network::RadarrEvent, NetworkTrait}, }; @@ -85,19 +84,28 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, RadarrDeleteCommand> for RadarrDeleteComm } } - async fn handle(self) -> Result<()> { - match self.command { + async fn handle(self) -> Result { + let result = match self.command { RadarrDeleteCommand::BlocklistItem { blocklist_item_id } => { - execute_network_event!( - self, - RadarrEvent::DeleteBlocklistItem(Some(blocklist_item_id)) - ); + let resp = self + .network + .handle_network_event((RadarrEvent::DeleteBlocklistItem(Some(blocklist_item_id))).into()) + .await?; + serde_json::to_string_pretty(&resp)? } RadarrDeleteCommand::Download { download_id } => { - execute_network_event!(self, RadarrEvent::DeleteDownload(Some(download_id))); + let resp = self + .network + .handle_network_event((RadarrEvent::DeleteDownload(Some(download_id))).into()) + .await?; + serde_json::to_string_pretty(&resp)? } RadarrDeleteCommand::Indexer { indexer_id } => { - execute_network_event!(self, RadarrEvent::DeleteIndexer(Some(indexer_id))); + let resp = self + .network + .handle_network_event((RadarrEvent::DeleteIndexer(Some(indexer_id))).into()) + .await?; + serde_json::to_string_pretty(&resp)? } RadarrDeleteCommand::Movie { movie_id, @@ -109,16 +117,28 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, RadarrDeleteCommand> for RadarrDeleteComm delete_movie_files: delete_files_from_disk, add_list_exclusion, }; - execute_network_event!(self, RadarrEvent::DeleteMovie(Some(delete_movie_params))); + let resp = self + .network + .handle_network_event((RadarrEvent::DeleteMovie(Some(delete_movie_params))).into()) + .await?; + serde_json::to_string_pretty(&resp)? } RadarrDeleteCommand::RootFolder { root_folder_id } => { - execute_network_event!(self, RadarrEvent::DeleteRootFolder(Some(root_folder_id))); + let resp = self + .network + .handle_network_event((RadarrEvent::DeleteRootFolder(Some(root_folder_id))).into()) + .await?; + serde_json::to_string_pretty(&resp)? } RadarrDeleteCommand::Tag { tag_id } => { - execute_network_event!(self, RadarrEvent::DeleteTag(tag_id)); + let resp = self + .network + .handle_network_event((RadarrEvent::DeleteTag(tag_id)).into()) + .await?; + serde_json::to_string_pretty(&resp)? } - } + }; - Ok(()) + Ok(result) } } diff --git a/src/cli/radarr/edit_command_handler.rs b/src/cli/radarr/edit_command_handler.rs index 666828c..f3396c9 100644 --- a/src/cli/radarr/edit_command_handler.rs +++ b/src/cli/radarr/edit_command_handler.rs @@ -7,7 +7,6 @@ use tokio::sync::Mutex; use crate::{ app::App, cli::{mutex_flags_or_default, mutex_flags_or_option, CliCommandHandler, Command}, - execute_network_event, models::{ radarr_models::{ EditCollectionParams, EditIndexerParams, EditMovieParams, IndexerSettings, @@ -339,8 +338,8 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, RadarrEditCommand> for RadarrEditCommandH } } - async fn handle(self) -> Result<()> { - match self.command { + async fn handle(self) -> Result { + let result = match self.command { RadarrEditCommand::AllIndexerSettings { allow_hardcoded_subs, disable_allow_hardcoded_subs, @@ -389,11 +388,13 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, RadarrEditCommand> for RadarrEditCommandH }) .into(), }; - execute_network_event!( - self, - RadarrEvent::EditAllIndexerSettings(Some(params)), - "All indexer settings updated" - ); + self + .network + .handle_network_event((RadarrEvent::EditAllIndexerSettings(Some(params))).into()) + .await?; + "All indexer settings updated".to_owned() + } else { + String::new() } } RadarrEditCommand::Collection { @@ -417,11 +418,11 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, RadarrEditCommand> for RadarrEditCommandH root_folder_path, search_on_add: search_on_add_value, }; - execute_network_event!( - self, - RadarrEvent::EditCollection(Some(edit_collection_params)), - "Collection Updated" - ); + self + .network + .handle_network_event((RadarrEvent::EditCollection(Some(edit_collection_params))).into()) + .await?; + "Collection updated".to_owned() } RadarrEditCommand::Indexer { indexer_id, @@ -458,11 +459,11 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, RadarrEditCommand> for RadarrEditCommandH clear_tags, }; - execute_network_event!( - self, - RadarrEvent::EditIndexer(Some(edit_indexer_params)), - "Indexer updated" - ); + self + .network + .handle_network_event((RadarrEvent::EditIndexer(Some(edit_indexer_params))).into()) + .await?; + "Indexer updated".to_owned() } RadarrEditCommand::Movie { movie_id, @@ -485,14 +486,14 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, RadarrEditCommand> for RadarrEditCommandH clear_tags, }; - execute_network_event!( - self, - RadarrEvent::EditMovie(Some(edit_movie_params)), - "Movie updated" - ); + self + .network + .handle_network_event((RadarrEvent::EditMovie(Some(edit_movie_params))).into()) + .await?; + "Movie Updated".to_owned() } - } + }; - Ok(()) + Ok(result) } } diff --git a/src/cli/radarr/get_command_handler.rs b/src/cli/radarr/get_command_handler.rs index 1c398ab..4df2595 100644 --- a/src/cli/radarr/get_command_handler.rs +++ b/src/cli/radarr/get_command_handler.rs @@ -7,7 +7,6 @@ use tokio::sync::Mutex; use crate::{ app::App, cli::{CliCommandHandler, Command}, - execute_network_event, network::{radarr_network::RadarrEvent, NetworkTrait}, }; @@ -72,28 +71,52 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, RadarrGetCommand> for RadarrGetCommandHan } } - async fn handle(self) -> Result<()> { - match self.command { + async fn handle(self) -> Result { + let result = match self.command { RadarrGetCommand::AllIndexerSettings => { - execute_network_event!(self, RadarrEvent::GetAllIndexerSettings); + let resp = self + .network + .handle_network_event((RadarrEvent::GetAllIndexerSettings).into()) + .await?; + serde_json::to_string_pretty(&resp)? } RadarrGetCommand::HostConfig => { - execute_network_event!(self, RadarrEvent::GetHostConfig); + let resp = self + .network + .handle_network_event((RadarrEvent::GetHostConfig).into()) + .await?; + serde_json::to_string_pretty(&resp)? } RadarrGetCommand::MovieDetails { movie_id } => { - execute_network_event!(self, RadarrEvent::GetMovieDetails(Some(movie_id))); + let resp = self + .network + .handle_network_event((RadarrEvent::GetMovieDetails(Some(movie_id))).into()) + .await?; + serde_json::to_string_pretty(&resp)? } RadarrGetCommand::MovieHistory { movie_id } => { - execute_network_event!(self, RadarrEvent::GetMovieHistory(Some(movie_id))); + let resp = self + .network + .handle_network_event((RadarrEvent::GetMovieHistory(Some(movie_id))).into()) + .await?; + serde_json::to_string_pretty(&resp)? } RadarrGetCommand::SecurityConfig => { - execute_network_event!(self, RadarrEvent::GetSecurityConfig); + let resp = self + .network + .handle_network_event((RadarrEvent::GetSecurityConfig).into()) + .await?; + serde_json::to_string_pretty(&resp)? } RadarrGetCommand::SystemStatus => { - execute_network_event!(self, RadarrEvent::GetStatus); + let resp = self + .network + .handle_network_event((RadarrEvent::GetStatus).into()) + .await?; + serde_json::to_string_pretty(&resp)? } - } + }; - Ok(()) + Ok(result) } } diff --git a/src/cli/radarr/list_command_handler.rs b/src/cli/radarr/list_command_handler.rs index e536d7f..0fc4dfb 100644 --- a/src/cli/radarr/list_command_handler.rs +++ b/src/cli/radarr/list_command_handler.rs @@ -7,7 +7,6 @@ use tokio::sync::Mutex; use crate::{ app::App, cli::{CliCommandHandler, Command}, - execute_network_event, network::{radarr_network::RadarrEvent, NetworkTrait}, }; @@ -87,19 +86,35 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, RadarrListCommand> for RadarrListCommandH } } - async fn handle(self) -> Result<()> { - match self.command { + async fn handle(self) -> Result { + let result = match self.command { RadarrListCommand::Blocklist => { - execute_network_event!(self, RadarrEvent::GetBlocklist); + let resp = self + .network + .handle_network_event((RadarrEvent::GetBlocklist).into()) + .await?; + serde_json::to_string_pretty(&resp)? } RadarrListCommand::Collections => { - execute_network_event!(self, RadarrEvent::GetCollections); + let resp = self + .network + .handle_network_event((RadarrEvent::GetCollections).into()) + .await?; + serde_json::to_string_pretty(&resp)? } RadarrListCommand::Downloads => { - execute_network_event!(self, RadarrEvent::GetDownloads); + let resp = self + .network + .handle_network_event((RadarrEvent::GetDownloads).into()) + .await?; + serde_json::to_string_pretty(&resp)? } RadarrListCommand::Indexers => { - execute_network_event!(self, RadarrEvent::GetIndexers); + let resp = self + .network + .handle_network_event((RadarrEvent::GetIndexers).into()) + .await?; + serde_json::to_string_pretty(&resp)? } RadarrListCommand::Logs { events, @@ -113,39 +128,69 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, RadarrListCommand> for RadarrListCommandH if output_in_log_format { let log_lines = self.app.lock().await.data.radarr_data.logs.items.clone(); - let json = serde_json::to_string_pretty(&log_lines)?; - println!("{}", json); + serde_json::to_string_pretty(&log_lines)? } else { - let json = serde_json::to_string_pretty(&logs)?; - println!("{}", json); + serde_json::to_string_pretty(&logs)? } } RadarrListCommand::Movies => { - execute_network_event!(self, RadarrEvent::GetMovies); + let resp = self + .network + .handle_network_event((RadarrEvent::GetMovies).into()) + .await?; + serde_json::to_string_pretty(&resp)? } RadarrListCommand::MovieCredits { movie_id } => { - execute_network_event!(self, RadarrEvent::GetMovieCredits(Some(movie_id))); + let resp = self + .network + .handle_network_event((RadarrEvent::GetMovieCredits(Some(movie_id))).into()) + .await?; + serde_json::to_string_pretty(&resp)? } RadarrListCommand::QualityProfiles => { - execute_network_event!(self, RadarrEvent::GetQualityProfiles); + let resp = self + .network + .handle_network_event((RadarrEvent::GetQualityProfiles).into()) + .await?; + serde_json::to_string_pretty(&resp)? } RadarrListCommand::QueuedEvents => { - execute_network_event!(self, RadarrEvent::GetQueuedEvents); + let resp = self + .network + .handle_network_event((RadarrEvent::GetQueuedEvents).into()) + .await?; + serde_json::to_string_pretty(&resp)? } RadarrListCommand::RootFolders => { - execute_network_event!(self, RadarrEvent::GetRootFolders); + let resp = self + .network + .handle_network_event((RadarrEvent::GetRootFolders).into()) + .await?; + serde_json::to_string_pretty(&resp)? } RadarrListCommand::Tags => { - execute_network_event!(self, RadarrEvent::GetTags); + let resp = self + .network + .handle_network_event((RadarrEvent::GetTags).into()) + .await?; + serde_json::to_string_pretty(&resp)? } RadarrListCommand::Tasks => { - execute_network_event!(self, RadarrEvent::GetTasks); + let resp = self + .network + .handle_network_event((RadarrEvent::GetTasks).into()) + .await?; + serde_json::to_string_pretty(&resp)? } RadarrListCommand::Updates => { - execute_network_event!(self, RadarrEvent::GetUpdates); + let resp = self + .network + .handle_network_event((RadarrEvent::GetUpdates).into()) + .await?; + serde_json::to_string_pretty(&resp)? } - } + }; - Ok(()) + Ok(result) } } diff --git a/src/cli/radarr/mod.rs b/src/cli/radarr/mod.rs index 96c7938..2bb9e34 100644 --- a/src/cli/radarr/mod.rs +++ b/src/cli/radarr/mod.rs @@ -12,7 +12,6 @@ use tokio::sync::Mutex; use crate::app::App; use crate::cli::CliCommandHandler; -use crate::execute_network_event; use crate::models::radarr_models::{ReleaseDownloadBody, TaskName}; use crate::network::radarr_network::RadarrEvent; use crate::network::NetworkTrait; @@ -155,8 +154,8 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, RadarrCommand> for RadarrCliHandler<'a, ' } } - async fn handle(self) -> Result<()> { - match self.command { + async fn handle(self) -> Result { + let result = match self.command { RadarrCommand::Add(add_command) => { RadarrAddCommandHandler::with(self.app, add_command, self.network) .handle() @@ -192,7 +191,11 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, RadarrCommand> for RadarrCliHandler<'a, ' .network .handle_network_event(RadarrEvent::GetBlocklist.into()) .await?; - execute_network_event!(self, RadarrEvent::ClearBlocklist); + let resp = self + .network + .handle_network_event((RadarrEvent::ClearBlocklist).into()) + .await?; + serde_json::to_string_pretty(&resp)? } RadarrCommand::DownloadRelease { guid, @@ -204,29 +207,57 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, RadarrCommand> for RadarrCliHandler<'a, ' indexer_id, movie_id, }; - execute_network_event!(self, RadarrEvent::DownloadRelease(Some(params))); + let resp = self + .network + .handle_network_event((RadarrEvent::DownloadRelease(Some(params))).into()) + .await?; + serde_json::to_string_pretty(&resp)? } RadarrCommand::ManualSearch { movie_id } => { println!("Searching for releases. This may take a minute..."); - execute_network_event!(self, RadarrEvent::GetReleases(Some(movie_id))); + let resp = self + .network + .handle_network_event((RadarrEvent::GetReleases(Some(movie_id))).into()) + .await?; + serde_json::to_string_pretty(&resp)? } RadarrCommand::SearchNewMovie { query } => { - execute_network_event!(self, RadarrEvent::SearchNewMovie(Some(query))); + let resp = self + .network + .handle_network_event((RadarrEvent::SearchNewMovie(Some(query))).into()) + .await?; + serde_json::to_string_pretty(&resp)? } RadarrCommand::StartTask { task_name } => { - execute_network_event!(self, RadarrEvent::StartTask(Some(task_name))); + let resp = self + .network + .handle_network_event((RadarrEvent::StartTask(Some(task_name))).into()) + .await?; + serde_json::to_string_pretty(&resp)? } RadarrCommand::TestIndexer { indexer_id } => { - execute_network_event!(self, RadarrEvent::TestIndexer(Some(indexer_id))); + let resp = self + .network + .handle_network_event((RadarrEvent::TestIndexer(Some(indexer_id))).into()) + .await?; + serde_json::to_string_pretty(&resp)? } RadarrCommand::TestAllIndexers => { - execute_network_event!(self, RadarrEvent::TestAllIndexers); + let resp = self + .network + .handle_network_event((RadarrEvent::TestAllIndexers).into()) + .await?; + serde_json::to_string_pretty(&resp)? } RadarrCommand::TriggerAutomaticSearch { movie_id } => { - execute_network_event!(self, RadarrEvent::TriggerAutomaticSearch(Some(movie_id))); + let resp = self + .network + .handle_network_event((RadarrEvent::TriggerAutomaticSearch(Some(movie_id))).into()) + .await?; + serde_json::to_string_pretty(&resp)? } - } + }; - Ok(()) + Ok(result) } } diff --git a/src/cli/radarr/refresh_command_handler.rs b/src/cli/radarr/refresh_command_handler.rs index 5bb0e73..201be01 100644 --- a/src/cli/radarr/refresh_command_handler.rs +++ b/src/cli/radarr/refresh_command_handler.rs @@ -7,7 +7,6 @@ use tokio::sync::Mutex; use crate::{ app::App, cli::{CliCommandHandler, Command}, - execute_network_event, network::{radarr_network::RadarrEvent, NetworkTrait}, }; @@ -63,22 +62,38 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, RadarrRefreshCommand> } } - async fn handle(self) -> Result<()> { - match self.command { + async fn handle(self) -> Result { + let result = match self.command { RadarrRefreshCommand::AllMovies => { - execute_network_event!(self, RadarrEvent::UpdateAllMovies); + let resp = self + .network + .handle_network_event((RadarrEvent::UpdateAllMovies).into()) + .await?; + serde_json::to_string_pretty(&resp)? } RadarrRefreshCommand::Collections => { - execute_network_event!(self, RadarrEvent::UpdateCollections); + let resp = self + .network + .handle_network_event((RadarrEvent::UpdateCollections).into()) + .await?; + serde_json::to_string_pretty(&resp)? } RadarrRefreshCommand::Downloads => { - execute_network_event!(self, RadarrEvent::UpdateDownloads); + let resp = self + .network + .handle_network_event((RadarrEvent::UpdateDownloads).into()) + .await?; + serde_json::to_string_pretty(&resp)? } RadarrRefreshCommand::Movie { movie_id } => { - execute_network_event!(self, RadarrEvent::UpdateAndScan(Some(movie_id))); + let resp = self + .network + .handle_network_event((RadarrEvent::UpdateAndScan(Some(movie_id))).into()) + .await?; + serde_json::to_string_pretty(&resp)? } - } + }; - Ok(()) + Ok(result) } } diff --git a/src/cli/sonarr/delete_command_handler.rs b/src/cli/sonarr/delete_command_handler.rs index cc73e74..2bf8991 100644 --- a/src/cli/sonarr/delete_command_handler.rs +++ b/src/cli/sonarr/delete_command_handler.rs @@ -7,7 +7,6 @@ use tokio::sync::Mutex; use crate::{ app::App, cli::{CliCommandHandler, Command}, - execute_network_event, network::{sonarr_network::SonarrEvent, NetworkTrait}, }; @@ -55,16 +54,17 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, SonarrDeleteCommand> for SonarrDeleteComm } } - async fn handle(self) -> Result<()> { - match self.command { + async fn handle(self) -> Result { + let resp = match self.command { SonarrDeleteCommand::BlocklistItem { blocklist_item_id } => { - execute_network_event!( - self, - SonarrEvent::DeleteBlocklistItem(Some(blocklist_item_id)) - ); + let resp = self + .network + .handle_network_event((SonarrEvent::DeleteBlocklistItem(Some(blocklist_item_id))).into()) + .await?; + serde_json::to_string_pretty(&resp)? } - } + }; - Ok(()) + Ok(resp) } } diff --git a/src/cli/sonarr/get_command_handler.rs b/src/cli/sonarr/get_command_handler.rs index 7af1ee6..091ef87 100644 --- a/src/cli/sonarr/get_command_handler.rs +++ b/src/cli/sonarr/get_command_handler.rs @@ -7,7 +7,6 @@ use tokio::sync::Mutex; use crate::{ app::App, cli::{CliCommandHandler, Command}, - execute_network_event, network::{sonarr_network::SonarrEvent, NetworkTrait}, }; @@ -72,28 +71,52 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, SonarrGetCommand> for SonarrGetCommandHan } } - async fn handle(self) -> Result<()> { - match self.command { + async fn handle(self) -> Result { + let result = match self.command { SonarrGetCommand::AllIndexerSettings => { - execute_network_event!(self, SonarrEvent::GetAllIndexerSettings); + let resp = self + .network + .handle_network_event((SonarrEvent::GetAllIndexerSettings).into()) + .await?; + serde_json::to_string_pretty(&resp)? } SonarrGetCommand::EpisodeDetails { episode_id } => { - execute_network_event!(self, SonarrEvent::GetEpisodeDetails(Some(episode_id))); + let resp = self + .network + .handle_network_event((SonarrEvent::GetEpisodeDetails(Some(episode_id))).into()) + .await?; + serde_json::to_string_pretty(&resp)? } SonarrGetCommand::HostConfig => { - execute_network_event!(self, SonarrEvent::GetHostConfig); + let resp = self + .network + .handle_network_event((SonarrEvent::GetHostConfig).into()) + .await?; + serde_json::to_string_pretty(&resp)? } SonarrGetCommand::SecurityConfig => { - execute_network_event!(self, SonarrEvent::GetSecurityConfig); + let resp = self + .network + .handle_network_event((SonarrEvent::GetSecurityConfig).into()) + .await?; + serde_json::to_string_pretty(&resp)? } SonarrGetCommand::SeriesDetails { series_id } => { - execute_network_event!(self, SonarrEvent::GetSeriesDetails(Some(series_id))); + let resp = self + .network + .handle_network_event((SonarrEvent::GetSeriesDetails(Some(series_id))).into()) + .await?; + serde_json::to_string_pretty(&resp)? } SonarrGetCommand::SystemStatus => { - execute_network_event!(self, SonarrEvent::GetStatus); + let resp = self + .network + .handle_network_event((SonarrEvent::GetStatus).into()) + .await?; + serde_json::to_string_pretty(&resp)? } - } + }; - Ok(()) + Ok(result) } } diff --git a/src/cli/sonarr/list_command_handler.rs b/src/cli/sonarr/list_command_handler.rs index b95ac8d..567d2db 100644 --- a/src/cli/sonarr/list_command_handler.rs +++ b/src/cli/sonarr/list_command_handler.rs @@ -7,7 +7,6 @@ use tokio::sync::Mutex; use crate::{ app::App, cli::{CliCommandHandler, Command}, - execute_network_event, network::{sonarr_network::SonarrEvent, NetworkTrait}, }; @@ -91,22 +90,42 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, SonarrListCommand> for SonarrListCommandH } } - async fn handle(self) -> Result<()> { - match self.command { + async fn handle(self) -> Result { + let result = match self.command { SonarrListCommand::Blocklist => { - execute_network_event!(self, SonarrEvent::GetBlocklist); + let resp = self + .network + .handle_network_event((SonarrEvent::GetBlocklist).into()) + .await?; + serde_json::to_string_pretty(&resp)? } SonarrListCommand::Downloads => { - execute_network_event!(self, SonarrEvent::GetDownloads); + let resp = self + .network + .handle_network_event((SonarrEvent::GetDownloads).into()) + .await?; + serde_json::to_string_pretty(&resp)? } SonarrListCommand::Episodes { series_id } => { - execute_network_event!(self, SonarrEvent::GetEpisodes(Some(series_id))); + let resp = self + .network + .handle_network_event((SonarrEvent::GetEpisodes(Some(series_id))).into()) + .await?; + serde_json::to_string_pretty(&resp)? } SonarrListCommand::History { events: items } => { - execute_network_event!(self, SonarrEvent::GetHistory(Some(items))); + let resp = self + .network + .handle_network_event((SonarrEvent::GetHistory(Some(items))).into()) + .await?; + serde_json::to_string_pretty(&resp)? } SonarrListCommand::Indexers => { - execute_network_event!(self, SonarrEvent::GetIndexers); + let resp = self + .network + .handle_network_event((SonarrEvent::GetIndexers).into()) + .await?; + serde_json::to_string_pretty(&resp)? } SonarrListCommand::Logs { events, @@ -120,27 +139,41 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, SonarrListCommand> for SonarrListCommandH if output_in_log_format { let log_lines = self.app.lock().await.data.sonarr_data.logs.items.clone(); - let json = serde_json::to_string_pretty(&log_lines)?; - println!("{}", json); + serde_json::to_string_pretty(&log_lines)? } else { - let json = serde_json::to_string_pretty(&logs)?; - println!("{}", json); + serde_json::to_string_pretty(&logs)? } } SonarrListCommand::QualityProfiles => { - execute_network_event!(self, SonarrEvent::GetQualityProfiles); + let resp = self + .network + .handle_network_event((SonarrEvent::GetQualityProfiles).into()) + .await?; + serde_json::to_string_pretty(&resp)? } SonarrListCommand::QueuedEvents => { - execute_network_event!(self, SonarrEvent::GetQueuedEvents); + let resp = self + .network + .handle_network_event((SonarrEvent::GetQueuedEvents).into()) + .await?; + serde_json::to_string_pretty(&resp)? } SonarrListCommand::Series => { - execute_network_event!(self, SonarrEvent::ListSeries); + let resp = self + .network + .handle_network_event((SonarrEvent::ListSeries).into()) + .await?; + serde_json::to_string_pretty(&resp)? } SonarrListCommand::SeriesHistory { series_id } => { - execute_network_event!(self, SonarrEvent::GetSeriesHistory(Some(series_id))); + let resp = self + .network + .handle_network_event((SonarrEvent::GetSeriesHistory(Some(series_id))).into()) + .await?; + serde_json::to_string_pretty(&resp)? } - } + }; - Ok(()) + Ok(result) } } diff --git a/src/cli/sonarr/mod.rs b/src/cli/sonarr/mod.rs index 693ae01..52239a7 100644 --- a/src/cli/sonarr/mod.rs +++ b/src/cli/sonarr/mod.rs @@ -9,7 +9,6 @@ use tokio::sync::Mutex; use crate::{ app::App, - execute_network_event, network::{sonarr_network::SonarrEvent, NetworkTrait}, }; @@ -91,8 +90,8 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, SonarrCommand> for SonarrCliHandler<'a, ' } } - async fn handle(self) -> Result<()> { - match self.command { + async fn handle(self) -> Result { + let result = match self.command { SonarrCommand::Delete(delete_command) => { SonarrDeleteCommandHandler::with(self.app, delete_command, self.network) .handle() @@ -113,24 +112,35 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, SonarrCommand> for SonarrCliHandler<'a, ' .network .handle_network_event(SonarrEvent::GetBlocklist.into()) .await?; - execute_network_event!(self, SonarrEvent::ClearBlocklist); + let resp = self + .network + .handle_network_event(SonarrEvent::ClearBlocklist.into()) + .await?; + serde_json::to_string_pretty(&resp)? } SonarrCommand::ManualEpisodeSearch { episode_id } => { println!("Searching for episode releases. This may take a minute..."); - execute_network_event!(self, SonarrEvent::GetEpisodeReleases(Some(episode_id))); + let resp = self + .network + .handle_network_event(SonarrEvent::GetEpisodeReleases(Some(episode_id)).into()) + .await?; + serde_json::to_string_pretty(&resp)? } SonarrCommand::ManualSeasonSearch { series_id, season_number, } => { println!("Searching for season releases. This may take a minute..."); - execute_network_event!( - self, - SonarrEvent::GetSeasonReleases(Some((series_id, season_number))) - ); + let resp = self + .network + .handle_network_event( + SonarrEvent::GetSeasonReleases(Some((series_id, season_number))).into(), + ) + .await?; + serde_json::to_string_pretty(&resp)? } - } + }; - Ok(()) + Ok(result) } } diff --git a/src/main.rs b/src/main.rs index b6ece85..299ac2d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,20 +1,13 @@ #![warn(rust_2018_idioms)] -use std::fs::{self, File}; -use std::io::BufReader; +use anyhow::Result; use std::panic::PanicHookInfo; use std::path::PathBuf; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; -use std::time::Duration; use std::{io, panic, process}; -use anyhow::anyhow; -use anyhow::Result; -use app::{log_and_print_error, AppConfig}; -use clap::{ - command, crate_authors, crate_description, crate_name, crate_version, CommandFactory, Parser, -}; +use clap::{crate_authors, crate_description, crate_name, crate_version, CommandFactory, Parser}; use clap_complete::generate; use colored::Colorize; use crossterm::execute; @@ -25,14 +18,17 @@ use log::{error, warn}; use network::NetworkTrait; use ratatui::backend::CrosstermBackend; use ratatui::Terminal; -use reqwest::{Certificate, Client}; +use reqwest::Client; use tokio::select; use tokio::sync::mpsc::Receiver; use tokio::sync::{mpsc, Mutex}; use tokio_util::sync::CancellationToken; -use utils::tail_logs; +use utils::{ + build_network_client, load_config, render_spinner, start_cli_no_spinner, start_cli_with_spinner, + tail_logs, +}; -use crate::app::App; +use crate::app::{App, AppConfig}; use crate::cli::Command; use crate::event::input_event::{Events, InputEvent}; use crate::event::Key; @@ -67,6 +63,13 @@ mod utils; struct Cli { #[command(subcommand)] command: Option, + #[arg( + long, + global = true, + env = "MANAGARR_DISABLE_SPINNER", + help = "Disable the spinner (can sometimes make parsing output challenging)" + )] + disable_spinner: bool, #[arg( long, global = true, @@ -91,6 +94,7 @@ async fn main() -> Result<()> { } else { confy::load("managarr", "config")? }; + let spinner_disabled = args.disable_spinner; config.validate(); let reqwest_client = build_network_client(&config); let (sync_network_tx, sync_network_rx) = mpsc::channel(500); @@ -113,14 +117,10 @@ async fn main() -> Result<()> { match args.command { Some(command) => match command { Command::Radarr(_) | Command::Sonarr(_) => { - config.verify_config_present_for_cli(&command); - app.lock().await.cli_mode = true; - let app_nw = Arc::clone(&app); - let mut network = Network::new(&app_nw, cancellation_token, reqwest_client); - - if let Err(e) = cli::handle_command(&app, command, &mut network).await { - eprintln!("error: {}", e.to_string().red()); - process::exit(1); + if spinner_disabled { + start_cli_no_spinner(config, reqwest_client, cancellation_token, app, command).await; + } else { + start_cli_with_spinner(config, reqwest_client, cancellation_token, app, command).await; } } Command::Completions { shell } => { @@ -237,65 +237,6 @@ fn panic_hook(info: &PanicHookInfo<'_>) { .unwrap(); } -fn load_config(path: &str) -> Result { - let file = File::open(path).map_err(|e| anyhow!(e))?; - let reader = BufReader::new(file); - let config = serde_yaml::from_reader(reader)?; - Ok(config) -} - -fn build_network_client(config: &AppConfig) -> Client { - let mut client_builder = Client::builder() - .pool_max_idle_per_host(10) - .http2_keep_alive_interval(Duration::from_secs(5)) - .tcp_keepalive(Duration::from_secs(5)); - - if let Some(radarr_config) = &config.radarr { - if let Some(ref cert_path) = &radarr_config.ssl_cert_path { - let cert = create_cert(cert_path, "Radarr"); - client_builder = client_builder.add_root_certificate(cert); - } - } - - if let Some(sonarr_config) = &config.sonarr { - if let Some(ref cert_path) = &sonarr_config.ssl_cert_path { - let cert = create_cert(cert_path, "Sonarr"); - client_builder = client_builder.add_root_certificate(cert); - } - } - - match client_builder.build() { - Ok(client) => client, - Err(e) => { - error!("Unable to create reqwest client: {}", e); - eprintln!("error: {}", e.to_string().red()); - process::exit(1); - } - } -} - -fn create_cert(cert_path: &String, servarr_name: &str) -> Certificate { - match fs::read(cert_path) { - Ok(cert) => match Certificate::from_pem(&cert) { - Ok(certificate) => certificate, - Err(_) => { - log_and_print_error(format!( - "Unable to read the specified {} SSL certificate", - servarr_name - )); - process::exit(1); - } - }, - Err(_) => { - log_and_print_error(format!( - "Unable to open specified {} SSL certificate", - servarr_name - )); - process::exit(1); - } - } -} - #[cfg(not(debug_assertions))] fn panic_hook(info: &PanicHookInfo<'_>) { use human_panic::{handle_dump, metadata, print_msg}; diff --git a/src/models/sonarr_models.rs b/src/models/sonarr_models.rs index 2fdae1a..aa22e2b 100644 --- a/src/models/sonarr_models.rs +++ b/src/models/sonarr_models.rs @@ -14,7 +14,7 @@ use super::{ HostConfig, Indexer, Language, LogResponse, QualityProfile, QualityWrapper, QueueEvent, Release, SecurityConfig, }, - HorizontallyScrollableText, Serdeable, + EnumDisplayStyle, HorizontallyScrollableText, Serdeable, }; #[cfg(test)] @@ -43,7 +43,7 @@ pub struct BlocklistResponse { pub records: Vec, } -#[derive(Derivative, Serialize, Deserialize, Debug, Default, Clone, PartialEq, Eq)] +#[derive(Derivative, Serialize, Deserialize, Debug, Default, Clone, PartialEq)] #[serde(rename_all = "camelCase")] pub struct DownloadRecord { pub title: String, @@ -52,16 +52,18 @@ pub struct DownloadRecord { pub id: i64, #[serde(deserialize_with = "super::from_i64")] pub episode_id: i64, - #[serde(deserialize_with = "super::from_i64")] - pub size: i64, - #[serde(deserialize_with = "super::from_i64")] - pub sizeleft: i64, + #[serde(deserialize_with = "super::from_f64")] + pub size: f64, + #[serde(deserialize_with = "super::from_f64")] + pub sizeleft: f64, pub output_path: Option, #[serde(default)] pub indexer: String, pub download_client: String, } +impl Eq for DownloadRecord {} + #[derive(Default, Clone, Serialize, Deserialize, Debug, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct DownloadsResponse { @@ -242,8 +244,8 @@ impl Display for SeriesType { } } -impl SeriesType { - pub fn to_display_str<'a>(self) -> &'a str { +impl<'a> EnumDisplayStyle<'a> for SeriesType { + fn to_display_str(self) -> &'a str { match self { SeriesType::Standard => "Standard", SeriesType::Daily => "Daily", @@ -293,8 +295,8 @@ impl Display for SeriesStatus { } } -impl SeriesStatus { - pub fn to_display_str<'a>(self) -> &'a str { +impl<'a> EnumDisplayStyle<'a> for SeriesStatus { + fn to_display_str(self) -> &'a str { match self { SeriesStatus::Continuing => "Continuing", SeriesStatus::Ended => "Ended", @@ -313,8 +315,62 @@ pub struct SonarrHistoryWrapper { #[derive(Serialize, Deserialize, Default, Debug, Clone, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct SonarrHistoryData { - pub dropped_path: String, - pub imported_path: String, + pub dropped_path: Option, + pub imported_path: Option, + pub indexer: Option, + pub release_group: Option, + pub series_match_type: Option, + pub nzb_info_url: Option, + pub download_client_name: Option, + pub age: Option, + pub published_date: Option>, + pub message: Option, + pub reason: Option, +} + +#[derive(Serialize, Deserialize, Default, Debug, Clone, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub enum SonarrHistoryEventType { + #[default] + Unknown, + Grabbed, + SeriesFolderImported, + DownloadFolderImported, + DownloadFailed, + EpisodeFileDeleted, + EpisodeFileRenamed, + DownloadIgnored, +} + +impl Display for SonarrHistoryEventType { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + let event_type = match self { + SonarrHistoryEventType::Unknown => "unknown", + SonarrHistoryEventType::Grabbed => "grabbed", + SonarrHistoryEventType::SeriesFolderImported => "seriesFolderImported", + SonarrHistoryEventType::DownloadFolderImported => "downloadFolderImported", + SonarrHistoryEventType::DownloadFailed => "downloadFailed", + SonarrHistoryEventType::EpisodeFileDeleted => "episodeFileDeleted", + SonarrHistoryEventType::EpisodeFileRenamed => "episodeFileRenamed", + SonarrHistoryEventType::DownloadIgnored => "downloadIgnored", + }; + write!(f, "{event_type}") + } +} + +impl<'a> EnumDisplayStyle<'a> for SonarrHistoryEventType { + fn to_display_str(self) -> &'a str { + match self { + SonarrHistoryEventType::Unknown => "Unknown", + SonarrHistoryEventType::Grabbed => "Grabbed", + SonarrHistoryEventType::SeriesFolderImported => "Series Folder Imported", + SonarrHistoryEventType::DownloadFolderImported => "Download Folder Imported", + SonarrHistoryEventType::DownloadFailed => "Download Failed", + SonarrHistoryEventType::EpisodeFileDeleted => "Episode File Deleted", + SonarrHistoryEventType::EpisodeFileRenamed => "Episode File Renamed", + SonarrHistoryEventType::DownloadIgnored => "Download Ignored", + } + } } #[derive(Serialize, Deserialize, Default, Debug, Clone, PartialEq, Eq)] @@ -326,7 +382,7 @@ pub struct SonarrHistoryItem { #[serde(deserialize_with = "super::from_i64")] pub episode_id: i64, pub quality: QualityWrapper, - pub languages: Vec, + pub language: Language, pub date: DateTime, pub event_type: String, pub data: SonarrHistoryData, diff --git a/src/models/sonarr_models_tests.rs b/src/models/sonarr_models_tests.rs index 81a2276..b65779f 100644 --- a/src/models/sonarr_models_tests.rs +++ b/src/models/sonarr_models_tests.rs @@ -9,10 +9,10 @@ mod tests { }, sonarr_models::{ BlocklistItem, BlocklistResponse, DownloadRecord, DownloadsResponse, Episode, - IndexerSettings, Series, SeriesStatus, SeriesType, SonarrHistoryItem, SonarrSerdeable, - SystemStatus, + IndexerSettings, Series, SeriesStatus, SeriesType, SonarrHistoryEventType, SonarrHistoryItem, + SonarrSerdeable, SystemStatus, }, - Serdeable, + EnumDisplayStyle, Serdeable, }; #[test] @@ -56,6 +56,66 @@ mod tests { assert_str_eq!(SeriesType::Anime.to_display_str(), "Anime"); } + #[test] + fn test_sonarr_history_event_type_display() { + assert_str_eq!(SonarrHistoryEventType::Unknown.to_string(), "unknown",); + assert_str_eq!(SonarrHistoryEventType::Grabbed.to_string(), "grabbed",); + assert_str_eq!( + SonarrHistoryEventType::SeriesFolderImported.to_string(), + "seriesFolderImported", + ); + assert_str_eq!( + SonarrHistoryEventType::DownloadFolderImported.to_string(), + "downloadFolderImported", + ); + assert_str_eq!( + SonarrHistoryEventType::DownloadFailed.to_string(), + "downloadFailed", + ); + assert_str_eq!( + SonarrHistoryEventType::EpisodeFileDeleted.to_string(), + "episodeFileDeleted", + ); + assert_str_eq!( + SonarrHistoryEventType::EpisodeFileRenamed.to_string(), + "episodeFileRenamed", + ); + assert_str_eq!( + SonarrHistoryEventType::DownloadIgnored.to_string(), + "downloadIgnored", + ); + } + + #[test] + fn test_sonarr_history_event_type_to_display_str() { + assert_str_eq!(SonarrHistoryEventType::Unknown.to_display_str(), "Unknown",); + assert_str_eq!(SonarrHistoryEventType::Grabbed.to_display_str(), "Grabbed",); + assert_str_eq!( + SonarrHistoryEventType::SeriesFolderImported.to_display_str(), + "Series Folder Imported", + ); + assert_str_eq!( + SonarrHistoryEventType::DownloadFolderImported.to_display_str(), + "Download Folder Imported", + ); + assert_str_eq!( + SonarrHistoryEventType::DownloadFailed.to_display_str(), + "Download Failed", + ); + assert_str_eq!( + SonarrHistoryEventType::EpisodeFileDeleted.to_display_str(), + "Episode File Deleted", + ); + assert_str_eq!( + SonarrHistoryEventType::EpisodeFileRenamed.to_display_str(), + "Episode File Renamed", + ); + assert_str_eq!( + SonarrHistoryEventType::DownloadIgnored.to_display_str(), + "Download Ignored", + ); + } + #[test] fn test_sonarr_serdeable_from() { let sonarr_serdeable = SonarrSerdeable::Value(json!({})); diff --git a/src/utils.rs b/src/utils.rs index 816f0fb..b5e98db 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -2,13 +2,25 @@ use std::fs::{self, File}; use std::io::{BufRead, BufReader, Seek, SeekFrom}; use std::path::PathBuf; use std::process; +use std::sync::Arc; +use std::time::Duration; +use anyhow::anyhow; +use anyhow::Result; use colored::Colorize; -use log::LevelFilter; +use indicatif::{ProgressBar, ProgressStyle}; +use log::{error, LevelFilter}; use log4rs::append::file::FileAppender; use log4rs::config::{Appender, Root}; use log4rs::encode::pattern::PatternEncoder; use regex::Regex; +use reqwest::{Certificate, Client}; +use tokio::sync::Mutex; +use tokio_util::sync::CancellationToken; + +use crate::app::{log_and_print_error, App, AppConfig}; +use crate::cli::{self, Command}; +use crate::network::Network; #[cfg(test)] #[path = "utils_tests.rs"] @@ -122,3 +134,126 @@ fn colorize_log_line(line: &str, re: &Regex) -> String { line.to_string() } } + +pub(super) fn load_config(path: &str) -> Result { + let file = File::open(path).map_err(|e| anyhow!(e))?; + let reader = BufReader::new(file); + let config = serde_yaml::from_reader(reader)?; + Ok(config) +} + +pub(super) fn build_network_client(config: &AppConfig) -> Client { + let mut client_builder = Client::builder() + .pool_max_idle_per_host(10) + .http2_keep_alive_interval(Duration::from_secs(5)) + .tcp_keepalive(Duration::from_secs(5)); + + if let Some(radarr_config) = &config.radarr { + if let Some(ref cert_path) = &radarr_config.ssl_cert_path { + let cert = create_cert(cert_path, "Radarr"); + client_builder = client_builder.add_root_certificate(cert); + } + } + + if let Some(sonarr_config) = &config.sonarr { + if let Some(ref cert_path) = &sonarr_config.ssl_cert_path { + let cert = create_cert(cert_path, "Sonarr"); + client_builder = client_builder.add_root_certificate(cert); + } + } + + match client_builder.build() { + Ok(client) => client, + Err(e) => { + error!("Unable to create reqwest client: {}", e); + eprintln!("error: {}", e.to_string().red()); + process::exit(1); + } + } +} + +pub(super) fn create_cert(cert_path: &String, servarr_name: &str) -> Certificate { + match fs::read(cert_path) { + Ok(cert) => match Certificate::from_pem(&cert) { + Ok(certificate) => certificate, + Err(_) => { + log_and_print_error(format!( + "Unable to read the specified {} SSL certificate", + servarr_name + )); + process::exit(1); + } + }, + Err(_) => { + log_and_print_error(format!( + "Unable to open specified {} SSL certificate", + servarr_name + )); + process::exit(1); + } + } +} + +pub(super) fn render_spinner() -> ProgressBar { + let pb = ProgressBar::new_spinner(); + pb.enable_steady_tick(Duration::from_millis(60)); + pb.set_style( + ProgressStyle::with_template("{spinner:.blue}") + .unwrap() + .tick_strings(&[ + "⢀⠀", "⡀⠀", "⠄⠀", "⢂⠀", "⡂⠀", "⠅⠀", "⢃⠀", "⡃⠀", "⠍⠀", "⢋⠀", "⡋⠀", "⠍⠁", "⢋⠁", "⡋⠁", "⠍⠉", + "⠋⠉", "⠋⠉", "⠉⠙", "⠉⠙", "⠉⠩", "⠈⢙", "⠈⡙", "⢈⠩", "⡀⢙", "⠄⡙", "⢂⠩", "⡂⢘", "⠅⡘", "⢃⠨", "⡃⢐", + "⠍⡐", "⢋⠠", "⡋⢀", "⠍⡁", "⢋⠁", "⡋⠁", "⠍⠉", "⠋⠉", "⠋⠉", "⠉⠙", "⠉⠙", "⠉⠩", "⠈⢙", "⠈⡙", "⠈⠩", + "⠀⢙", "⠀⡙", "⠀⠩", "⠀⢘", "⠀⡘", "⠀⠨", "⠀⢐", "⠀⡐", "⠀⠠", "⠀⢀", "⠀⡀", + ]), + ); + pb.set_message("Querying..."); + pb +} + +pub(super) async fn start_cli_with_spinner( + config: AppConfig, + reqwest_client: Client, + cancellation_token: CancellationToken, + app: Arc>>, + command: Command, +) { + config.verify_config_present_for_cli(&command); + app.lock().await.cli_mode = true; + let pb = render_spinner(); + let app_nw = Arc::clone(&app); + let mut network = Network::new(&app_nw, cancellation_token, reqwest_client); + match cli::handle_command(&app, command, &mut network).await { + Ok(output) => { + pb.finish(); + println!("{}", output); + } + Err(e) => { + pb.finish(); + eprintln!("error: {}", e.to_string().red()); + process::exit(1); + } + } +} + +pub(super) async fn start_cli_no_spinner( + config: AppConfig, + reqwest_client: Client, + cancellation_token: CancellationToken, + app: Arc>>, + command: Command, +) { + config.verify_config_present_for_cli(&command); + app.lock().await.cli_mode = true; + let app_nw = Arc::clone(&app); + let mut network = Network::new(&app_nw, cancellation_token, reqwest_client); + match cli::handle_command(&app, command, &mut network).await { + Ok(output) => { + println!("{}", output); + } + Err(e) => { + eprintln!("error: {}", e.to_string().red()); + process::exit(1); + } + } +} From d7d223400eb259bfe7d42f418c039068a32d5c91 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Wed, 20 Nov 2024 19:36:01 -0700 Subject: [PATCH 038/119] style(linter): Removed unused imports that were missed on the last commit --- src/main.rs | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/main.rs b/src/main.rs index 299ac2d..d828b28 100644 --- a/src/main.rs +++ b/src/main.rs @@ -9,7 +9,6 @@ use std::{io, panic, process}; use clap::{crate_authors, crate_description, crate_name, crate_version, CommandFactory, Parser}; use clap_complete::generate; -use colored::Colorize; use crossterm::execute; use crossterm::terminal::{ disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen, @@ -24,11 +23,10 @@ use tokio::sync::mpsc::Receiver; use tokio::sync::{mpsc, Mutex}; use tokio_util::sync::CancellationToken; use utils::{ - build_network_client, load_config, render_spinner, start_cli_no_spinner, start_cli_with_spinner, - tail_logs, + build_network_client, load_config, start_cli_no_spinner, start_cli_with_spinner, tail_logs, }; -use crate::app::{App, AppConfig}; +use crate::app::App; use crate::cli::Command; use crate::event::input_event::{Events, InputEvent}; use crate::event::Key; From fa4ec709c0bd932cabfc9cca9fd131d5d029d146 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Wed, 20 Nov 2024 19:59:32 -0700 Subject: [PATCH 039/119] feat(network): Support for fetching episode history --- src/network/sonarr_network.rs | 72 ++++- src/network/sonarr_network_tests.rs | 469 +++++++++++++++++++++++++++- 2 files changed, 539 insertions(+), 2 deletions(-) diff --git a/src/network/sonarr_network.rs b/src/network/sonarr_network.rs index 4eb89c8..5fbdef0 100644 --- a/src/network/sonarr_network.rs +++ b/src/network/sonarr_network.rs @@ -40,6 +40,7 @@ pub enum SonarrEvent { GetIndexers, GetEpisodeDetails(Option), GetEpisodes(Option), + GetEpisodeHistory(Option), GetLogs(Option), GetQualityProfiles, GetQueuedEvents, @@ -62,7 +63,7 @@ impl NetworkResource for SonarrEvent { SonarrEvent::GetBlocklist => "/blocklist?page=1&pageSize=10000", SonarrEvent::GetDownloads => "/queue", SonarrEvent::GetEpisodes(_) | SonarrEvent::GetEpisodeDetails(_) => "/episode", - SonarrEvent::GetHistory(_) => "/history", + SonarrEvent::GetHistory(_) | SonarrEvent::GetEpisodeHistory(_) => "/history", SonarrEvent::GetHostConfig | SonarrEvent::GetSecurityConfig => "/config/host", SonarrEvent::GetIndexers => "/indexer", SonarrEvent::GetLogs(_) => "/log", @@ -111,6 +112,10 @@ impl<'a, 'b> Network<'a, 'b> { .get_episode_details(episode_id) .await .map(SonarrSerdeable::from), + SonarrEvent::GetEpisodeHistory(episode_id) => self + .get_sonarr_episode_history(episode_id) + .await + .map(SonarrSerdeable::from), SonarrEvent::GetHistory(events) => self .get_sonarr_history(events) .await @@ -346,6 +351,71 @@ impl<'a, 'b> Network<'a, 'b> { .await } + async fn get_sonarr_episode_history( + &mut self, + episode_id: Option, + ) -> Result { + let id = self.extract_episode_id(episode_id).await; + info!("Fetching Sonarr history for episode with ID: {id}"); + let event = SonarrEvent::GetEpisodeHistory(episode_id); + + let params = format!("episodeId={id}&pageSize=1000&sortDirection=descending&sortKey=date",); + let request_props = self + .request_props_from(event, RequestMethod::Get, None::<()>, None, Some(params)) + .await; + + self + .handle_request::<(), SonarrHistoryWrapper>(request_props, |history_response, mut app| { + if app.data.sonarr_data.season_details_modal.is_none() { + app.data.sonarr_data.season_details_modal = Some(SeasonDetailsModal::default()); + } + + if app + .data + .sonarr_data + .season_details_modal + .as_ref() + .unwrap() + .episode_details_modal + .is_none() + { + app + .data + .sonarr_data + .season_details_modal + .as_mut() + .unwrap() + .episode_details_modal = Some(EpisodeDetailsModal::default()); + } + + let mut history_vec = history_response.records; + history_vec.sort_by(|a, b| a.id.cmp(&b.id)); + app + .data + .sonarr_data + .season_details_modal + .as_mut() + .unwrap() + .episode_details_modal + .as_mut() + .unwrap() + .episode_history + .set_items(history_vec); + app + .data + .sonarr_data + .season_details_modal + .as_mut() + .unwrap() + .episode_details_modal + .as_mut() + .unwrap() + .episode_history + .apply_sorting_toggle(false); + }) + .await + } + async fn get_episode_details(&mut self, episode_id: Option) -> Result { info!("Fetching Sonarr episode details"); let event = SonarrEvent::GetEpisodeDetails(None); diff --git a/src/network/sonarr_network_tests.rs b/src/network/sonarr_network_tests.rs index 050ea84..12b0bbd 100644 --- a/src/network/sonarr_network_tests.rs +++ b/src/network/sonarr_network_tests.rs @@ -156,7 +156,10 @@ mod test { } #[rstest] - fn test_resource_history(#[values(SonarrEvent::GetHistory(None))] event: SonarrEvent) { + fn test_resource_history( + #[values(SonarrEvent::GetHistory(None), SonarrEvent::GetEpisodeHistory(None))] + event: SonarrEvent, + ) { assert_str_eq!(event.resource(), "/history"); } @@ -1280,6 +1283,470 @@ mod test { } } + #[tokio::test] + async fn test_handle_get_sonarr_episode_history_event() { + let history_json = json!({"records": [{ + "id": 123, + "sourceTitle": "z episode", + "episodeId": 1007, + "quality": { "quality": { "name": "Bluray-1080p" } }, + "language": { "name": "English" }, + "date": "2024-02-10T07:28:45Z", + "eventType": "grabbed", + "data": { + "droppedPath": "/nfs/nzbget/completed/series/Coolness/something.cool.mkv", + "importedPath": "/nfs/tv/Coolness/Season 1/Coolness - S01E01 - Something Cool Bluray-1080p.mkv" + } + }, + { + "id": 456, + "sourceTitle": "A Episode", + "episodeId": 2001, + "quality": { "quality": { "name": "Bluray-1080p" } }, + "language": { "name": "English" }, + "date": "2024-02-10T07:28:45Z", + "eventType": "grabbed", + "data": { + "droppedPath": "/nfs/nzbget/completed/series/Coolness/something.cool.mkv", + "importedPath": "/nfs/tv/Coolness/Season 1/Coolness - S01E01 - Something Cool Bluray-1080p.mkv" + } + }]}); + let response: SonarrHistoryWrapper = serde_json::from_value(history_json.clone()).unwrap(); + let expected_history_items = vec![ + SonarrHistoryItem { + id: 123, + episode_id: 1007, + source_title: "z episode".into(), + ..history_item() + }, + SonarrHistoryItem { + id: 456, + episode_id: 2001, + source_title: "A Episode".into(), + ..history_item() + }, + ]; + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(history_json), + None, + SonarrEvent::GetEpisodeHistory(None), + None, + Some("episodeId=1&pageSize=1000&sortDirection=descending&sortKey=date"), + ) + .await; + app_arc.lock().await.data.sonarr_data.season_details_modal = + Some(SeasonDetailsModal::default()); + app_arc + .lock() + .await + .data + .sonarr_data + .season_details_modal + .as_mut() + .unwrap() + .episodes + .set_items(vec![episode()]); + app_arc + .lock() + .await + .data + .sonarr_data + .season_details_modal + .as_mut() + .unwrap() + .episode_details_modal = Some(EpisodeDetailsModal::default()); + app_arc + .lock() + .await + .data + .sonarr_data + .season_details_modal + .as_mut() + .unwrap() + .episode_details_modal + .as_mut() + .unwrap() + .episode_history + .sort_asc = true; + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + if let SonarrSerdeable::SonarrHistoryWrapper(history) = network + .handle_sonarr_event(SonarrEvent::GetEpisodeHistory(None)) + .await + .unwrap() + { + async_server.assert_async().await; + assert_eq!( + app_arc + .lock() + .await + .data + .sonarr_data + .season_details_modal + .as_ref() + .unwrap() + .episode_details_modal + .as_ref() + .unwrap() + .episode_history + .items, + expected_history_items + ); + assert!( + app_arc + .lock() + .await + .data + .sonarr_data + .season_details_modal + .as_ref() + .unwrap() + .episode_details_modal + .as_ref() + .unwrap() + .episode_history + .sort_asc + ); + assert_eq!(history, response); + } + } + + #[tokio::test] + async fn test_handle_get_sonarr_episode_history_event_uses_provided_episode_id() { + let history_json = json!({"records": [{ + "id": 123, + "sourceTitle": "z episode", + "episodeId": 1007, + "quality": { "quality": { "name": "Bluray-1080p" } }, + "language": { "name": "English" }, + "date": "2024-02-10T07:28:45Z", + "eventType": "grabbed", + "data": { + "droppedPath": "/nfs/nzbget/completed/series/Coolness/something.cool.mkv", + "importedPath": "/nfs/tv/Coolness/Season 1/Coolness - S01E01 - Something Cool Bluray-1080p.mkv" + } + }, + { + "id": 456, + "sourceTitle": "A Episode", + "episodeId": 2001, + "quality": { "quality": { "name": "Bluray-1080p" } }, + "language": { "name": "English" }, + "date": "2024-02-10T07:28:45Z", + "eventType": "grabbed", + "data": { + "droppedPath": "/nfs/nzbget/completed/series/Coolness/something.cool.mkv", + "importedPath": "/nfs/tv/Coolness/Season 1/Coolness - S01E01 - Something Cool Bluray-1080p.mkv" + } + }]}); + let response: SonarrHistoryWrapper = serde_json::from_value(history_json.clone()).unwrap(); + let expected_history_items = vec![ + SonarrHistoryItem { + id: 123, + episode_id: 1007, + source_title: "z episode".into(), + ..history_item() + }, + SonarrHistoryItem { + id: 456, + episode_id: 2001, + source_title: "A Episode".into(), + ..history_item() + }, + ]; + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(history_json), + None, + SonarrEvent::GetEpisodeHistory(Some(2)), + None, + Some("episodeId=2&pageSize=1000&sortDirection=descending&sortKey=date"), + ) + .await; + app_arc.lock().await.data.sonarr_data.season_details_modal = + Some(SeasonDetailsModal::default()); + app_arc + .lock() + .await + .data + .sonarr_data + .season_details_modal + .as_mut() + .unwrap() + .episodes + .set_items(vec![episode()]); + app_arc + .lock() + .await + .data + .sonarr_data + .season_details_modal + .as_mut() + .unwrap() + .episode_details_modal = Some(EpisodeDetailsModal::default()); + app_arc + .lock() + .await + .data + .sonarr_data + .season_details_modal + .as_mut() + .unwrap() + .episode_details_modal + .as_mut() + .unwrap() + .episode_history + .sort_asc = true; + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + if let SonarrSerdeable::SonarrHistoryWrapper(history) = network + .handle_sonarr_event(SonarrEvent::GetEpisodeHistory(Some(2))) + .await + .unwrap() + { + async_server.assert_async().await; + assert_eq!( + app_arc + .lock() + .await + .data + .sonarr_data + .season_details_modal + .as_ref() + .unwrap() + .episode_details_modal + .as_ref() + .unwrap() + .episode_history + .items, + expected_history_items + ); + assert!( + app_arc + .lock() + .await + .data + .sonarr_data + .season_details_modal + .as_ref() + .unwrap() + .episode_details_modal + .as_ref() + .unwrap() + .episode_history + .sort_asc + ); + assert_eq!(history, response); + } + } + + #[tokio::test] + async fn test_handle_get_sonarr_episode_history_event_empty_episode_details_modal() { + let history_json = json!({"records": [{ + "id": 123, + "sourceTitle": "z episode", + "episodeId": 1007, + "quality": { "quality": { "name": "Bluray-1080p" } }, + "language": { "name": "English" }, + "date": "2024-02-10T07:28:45Z", + "eventType": "grabbed", + "data": { + "droppedPath": "/nfs/nzbget/completed/series/Coolness/something.cool.mkv", + "importedPath": "/nfs/tv/Coolness/Season 1/Coolness - S01E01 - Something Cool Bluray-1080p.mkv" + } + }, + { + "id": 456, + "sourceTitle": "A Episode", + "episodeId": 2001, + "quality": { "quality": { "name": "Bluray-1080p" } }, + "language": { "name": "English" }, + "date": "2024-02-10T07:28:45Z", + "eventType": "grabbed", + "data": { + "droppedPath": "/nfs/nzbget/completed/series/Coolness/something.cool.mkv", + "importedPath": "/nfs/tv/Coolness/Season 1/Coolness - S01E01 - Something Cool Bluray-1080p.mkv" + } + }]}); + let response: SonarrHistoryWrapper = serde_json::from_value(history_json.clone()).unwrap(); + let expected_history_items = vec![ + SonarrHistoryItem { + id: 123, + episode_id: 1007, + source_title: "z episode".into(), + ..history_item() + }, + SonarrHistoryItem { + id: 456, + episode_id: 2001, + source_title: "A Episode".into(), + ..history_item() + }, + ]; + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(history_json), + None, + SonarrEvent::GetEpisodeHistory(None), + None, + Some("episodeId=1&pageSize=1000&sortDirection=descending&sortKey=date"), + ) + .await; + app_arc.lock().await.data.sonarr_data.season_details_modal = + Some(SeasonDetailsModal::default()); + app_arc + .lock() + .await + .data + .sonarr_data + .season_details_modal + .as_mut() + .unwrap() + .episodes + .set_items(vec![episode()]); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + if let SonarrSerdeable::SonarrHistoryWrapper(history) = network + .handle_sonarr_event(SonarrEvent::GetEpisodeHistory(None)) + .await + .unwrap() + { + async_server.assert_async().await; + assert_eq!( + app_arc + .lock() + .await + .data + .sonarr_data + .season_details_modal + .as_ref() + .unwrap() + .episode_details_modal + .as_ref() + .unwrap() + .episode_history + .items, + expected_history_items + ); + assert!( + !app_arc + .lock() + .await + .data + .sonarr_data + .season_details_modal + .as_ref() + .unwrap() + .episode_details_modal + .as_ref() + .unwrap() + .episode_history + .sort_asc + ); + assert_eq!(history, response); + } + } + + #[tokio::test] + async fn test_handle_get_sonarr_episode_history_event_empty_season_details_modal() { + let history_json = json!({"records": [{ + "id": 123, + "sourceTitle": "z episode", + "episodeId": 1007, + "quality": { "quality": { "name": "Bluray-1080p" } }, + "language": { "name": "English" }, + "date": "2024-02-10T07:28:45Z", + "eventType": "grabbed", + "data": { + "droppedPath": "/nfs/nzbget/completed/series/Coolness/something.cool.mkv", + "importedPath": "/nfs/tv/Coolness/Season 1/Coolness - S01E01 - Something Cool Bluray-1080p.mkv" + } + }, + { + "id": 456, + "sourceTitle": "A Episode", + "episodeId": 2001, + "quality": { "quality": { "name": "Bluray-1080p" } }, + "language": { "name": "English" }, + "date": "2024-02-10T07:28:45Z", + "eventType": "grabbed", + "data": { + "droppedPath": "/nfs/nzbget/completed/series/Coolness/something.cool.mkv", + "importedPath": "/nfs/tv/Coolness/Season 1/Coolness - S01E01 - Something Cool Bluray-1080p.mkv" + } + }]}); + let response: SonarrHistoryWrapper = serde_json::from_value(history_json.clone()).unwrap(); + let expected_history_items = vec![ + SonarrHistoryItem { + id: 123, + episode_id: 1007, + source_title: "z episode".into(), + ..history_item() + }, + SonarrHistoryItem { + id: 456, + episode_id: 2001, + source_title: "A Episode".into(), + ..history_item() + }, + ]; + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(history_json), + None, + SonarrEvent::GetEpisodeHistory(Some(1)), + None, + Some("episodeId=1&pageSize=1000&sortDirection=descending&sortKey=date"), + ) + .await; + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + if let SonarrSerdeable::SonarrHistoryWrapper(history) = network + .handle_sonarr_event(SonarrEvent::GetEpisodeHistory(Some(1))) + .await + .unwrap() + { + async_server.assert_async().await; + assert_eq!( + app_arc + .lock() + .await + .data + .sonarr_data + .season_details_modal + .as_ref() + .unwrap() + .episode_details_modal + .as_ref() + .unwrap() + .episode_history + .items, + expected_history_items + ); + assert!( + !app_arc + .lock() + .await + .data + .sonarr_data + .season_details_modal + .as_ref() + .unwrap() + .episode_details_modal + .as_ref() + .unwrap() + .episode_history + .sort_asc + ); + assert_eq!(history, response); + } + } + #[tokio::test] async fn test_handle_get_episode_details_event_season_details_modal_not_required_in_cli_mode() { let response: Episode = serde_json::from_str(EPISODE_JSON).unwrap(); From 71870d939611f260542dac846231f5665ae271b4 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Wed, 20 Nov 2024 20:03:53 -0700 Subject: [PATCH 040/119] feat(cli): Support for fetching episode history events from Sonarr --- src/cli/sonarr/list_command_handler.rs | 16 ++++++ src/cli/sonarr/list_command_handler_tests.rs | 59 ++++++++++++++++++++ 2 files changed, 75 insertions(+) diff --git a/src/cli/sonarr/list_command_handler.rs b/src/cli/sonarr/list_command_handler.rs index 567d2db..697292b 100644 --- a/src/cli/sonarr/list_command_handler.rs +++ b/src/cli/sonarr/list_command_handler.rs @@ -31,6 +31,15 @@ pub enum SonarrListCommand { )] series_id: i64, }, + #[command(about = "Fetch all history events for the episode with the given ID")] + EpisodeHistory { + #[arg( + long, + help = "The Sonarr ID of the episode whose history you wish to fetch", + required = true + )] + episode_id: i64, + }, #[command(about = "Fetch all Sonarr history events")] History { #[arg(long, help = "How many history events to fetch", default_value_t = 500)] @@ -113,6 +122,13 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, SonarrListCommand> for SonarrListCommandH .await?; serde_json::to_string_pretty(&resp)? } + SonarrListCommand::EpisodeHistory { episode_id } => { + let resp = self + .network + .handle_network_event((SonarrEvent::GetEpisodeHistory(Some(episode_id))).into()) + .await?; + serde_json::to_string_pretty(&resp)? + } SonarrListCommand::History { events: items } => { let resp = self .network diff --git a/src/cli/sonarr/list_command_handler_tests.rs b/src/cli/sonarr/list_command_handler_tests.rs index 6d7d9c1..e0d2e8a 100644 --- a/src/cli/sonarr/list_command_handler_tests.rs +++ b/src/cli/sonarr/list_command_handler_tests.rs @@ -50,6 +50,39 @@ mod tests { ); } + #[test] + fn test_list_episode_history_requires_series_id() { + let result = + Cli::command().try_get_matches_from(["managarr", "sonarr", "list", "episode-history"]); + + assert!(result.is_err()); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_list_episode_history_success() { + let expected_args = SonarrListCommand::EpisodeHistory { episode_id: 1 }; + let result = Cli::try_parse_from([ + "managarr", + "sonarr", + "list", + "episode-history", + "--episode-id", + "1", + ]); + + assert!(result.is_ok()); + + if let Some(Command::Sonarr(SonarrCommand::List(episode_history_command))) = + result.unwrap().command + { + assert_eq!(episode_history_command, expected_args); + } + } + #[test] fn test_list_history_events_flag_requires_arguments() { let result = @@ -296,5 +329,31 @@ mod tests { assert!(result.is_ok()); } + + #[tokio::test] + async fn test_handle_list_episode_history_command() { + let expected_episode_id = 1; + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::( + SonarrEvent::GetEpisodeHistory(Some(expected_episode_id)).into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Sonarr(SonarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::default())); + let list_episode_history_command = SonarrListCommand::EpisodeHistory { episode_id: 1 }; + + let result = + SonarrListCommandHandler::with(&app_arc, list_episode_history_command, &mut mock_network) + .handle() + .await; + + assert!(result.is_ok()); + } } } From 4cdad182ef11e08c5436e810f7e0114cde57a03e Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Thu, 21 Nov 2024 12:44:57 -0700 Subject: [PATCH 041/119] docs(README): Updated the readme to use a table of checkmarks to indicate which features are present in the CLI and TUI --- README.md | 34 ++++++++++++++++++---------------- 1 file changed, 18 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index ecc81de..9d2a1b7 100644 --- a/README.md +++ b/README.md @@ -57,22 +57,24 @@ Please note that you will need to create and popular your configuration file fir ### Radarr -- [x] View your library, downloads, collections, and blocklist -- [x] View details of a specific movie including description, history, downloaded file info, or the credits -- [x] View details of any collection and the movies in them -- [x] View your host and security configs from the CLI to programmatically fetch the API token, among other settings -- [x] Search your library or collections -- [x] Add movies to your library -- [x] Delete movies, downloads, and indexers -- [x] Trigger automatic searches for movies -- [x] Trigger refresh and disk scan for movies, downloads, and collections -- [x] Manually search for movies -- [x] Edit your movies, collections, and indexers -- [x] Manage your tags -- [x] Manage your root folders -- [x] Manage your blocklist -- [x] View and browse logs, tasks, events queues, and updates -- [x] Manually trigger scheduled tasks +| TUI | CLI | Feature | +|-----|-----|----------------------------------------------------------------------------------------------------------------| +| ✅ | ✅ | View your library, downloads, collections, and blocklist | +| ✅ | ✅ | View details of a specific movie including description, history, downloaded file info, or the credits | +| ✅ | ✅ | View details of any collection and the movies in them | +| | ✅ | View your host and security configs from the CLI to programmatically fetch the API token, among other settings | +| ✅ | ✅ | Search your library or collections | +| ✅ | ✅ | Add movies to your library | +| ✅ | ✅ | Delete movies, downloads, and indexers | +| ✅ | ✅ | Trigger automatic searches for movies | +| ✅ | ✅ | Trigger refresh and disk scan for movies, downloads, and collections | +| ✅ | ✅ | Manually search for movies | +| ✅ | ✅ | Edit your movies, collections, and indexers | +| ✅ | ✅ | Manage your tags | +| ✅ | ✅ | Manage your root folders | +| ✅ | ✅ | Manage your blocklist | +| ✅ | ✅ | View and browse logs, tasks, events queues, and updates | +| ✅ | ✅ | Manually trigger scheduled tasks | ### Sonarr - [ ] Support for Sonarr From d2e3750de64c58eecfa38cf7977491621cc747fd Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Thu, 21 Nov 2024 13:04:21 -0700 Subject: [PATCH 042/119] style(README): Added a key and proper GitHub markdown emoji names instead of direct unicode symbols for the features tables --- README.md | 43 +++++++++++++++++++++++++------------------ 1 file changed, 25 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 9d2a1b7..32415e3 100644 --- a/README.md +++ b/README.md @@ -54,27 +54,34 @@ You can also clone this repo and run `make docker` to build a docker image local Please note that you will need to create and popular your configuration file first before starting the container. Otherwise the container will fail to start. ## Features +Key: + +| Symbol | Status | +|--------------------|-----------| +| :white_check_mark: | Supported | +| :x: | Missing | +| :no_entry_sign: | Won't Add | ### Radarr -| TUI | CLI | Feature | -|-----|-----|----------------------------------------------------------------------------------------------------------------| -| ✅ | ✅ | View your library, downloads, collections, and blocklist | -| ✅ | ✅ | View details of a specific movie including description, history, downloaded file info, or the credits | -| ✅ | ✅ | View details of any collection and the movies in them | -| | ✅ | View your host and security configs from the CLI to programmatically fetch the API token, among other settings | -| ✅ | ✅ | Search your library or collections | -| ✅ | ✅ | Add movies to your library | -| ✅ | ✅ | Delete movies, downloads, and indexers | -| ✅ | ✅ | Trigger automatic searches for movies | -| ✅ | ✅ | Trigger refresh and disk scan for movies, downloads, and collections | -| ✅ | ✅ | Manually search for movies | -| ✅ | ✅ | Edit your movies, collections, and indexers | -| ✅ | ✅ | Manage your tags | -| ✅ | ✅ | Manage your root folders | -| ✅ | ✅ | Manage your blocklist | -| ✅ | ✅ | View and browse logs, tasks, events queues, and updates | -| ✅ | ✅ | Manually trigger scheduled tasks | +| TUI | CLI | Feature | +|--------------------|--------------------|----------------------------------------------------------------------------------------------------------------| +| :white_check_mark: | :white_check_mark: | View your library, downloads, collections, and blocklist | +| :white_check_mark: | :white_check_mark: | View details of a specific movie including description, history, downloaded file info, or the credits | +| :white_check_mark: | :white_check_mark: | View details of any collection and the movies in them | +| :no_entry_sign: | :white_check_mark: | View your host and security configs from the CLI to programmatically fetch the API token, among other settings | +| :white_check_mark: | :white_check_mark: | Search your library or collections | +| :white_check_mark: | :white_check_mark: | Add movies to your library | +| :white_check_mark: | :white_check_mark: | Delete movies, downloads, and indexers | +| :white_check_mark: | :white_check_mark: | Trigger automatic searches for movies | +| :white_check_mark: | :white_check_mark: | Trigger refresh and disk scan for movies, downloads, and collections | +| :white_check_mark: | :white_check_mark: | Manually search for movies | +| :white_check_mark: | :white_check_mark: | Edit your movies, collections, and indexers | +| :white_check_mark: | :white_check_mark: | Manage your tags | +| :white_check_mark: | :white_check_mark: | Manage your root folders | +| :white_check_mark: | :white_check_mark: | Manage your blocklist | +| :white_check_mark: | :white_check_mark: | View and browse logs, tasks, events queues, and updates | +| :white_check_mark: | :white_check_mark: | Manually trigger scheduled tasks | ### Sonarr - [ ] Support for Sonarr From 6a0049eb8f55541f61ba8b40cf4d951e5b25a17c Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Thu, 21 Nov 2024 14:46:18 -0700 Subject: [PATCH 043/119] feat(network): Support for deleting a download from Sonarr --- README.md | 2 +- src/network/radarr_network.rs | 4 +- src/network/radarr_network_tests.rs | 4 +- src/network/sonarr_network.rs | 40 ++++++++++++++++++- src/network/sonarr_network_tests.rs | 59 ++++++++++++++++++++++++++++- 5 files changed, 102 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 32415e3..ec4cafb 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,7 @@ docker run --rm -it -v ~/.config/managarr/config.yml:/root/.config/managarr/conf You can also clone this repo and run `make docker` to build a docker image locally and run it using the above command. -Please note that you will need to create and popular your configuration file first before starting the container. Otherwise the container will fail to start. +Please note that you will need to create and popular your configuration file first before starting the container. Otherwise, the container will fail to start. ## Features Key: diff --git a/src/network/radarr_network.rs b/src/network/radarr_network.rs index aaf6d72..fa745a6 100644 --- a/src/network/radarr_network.rs +++ b/src/network/radarr_network.rs @@ -156,7 +156,7 @@ impl<'a, 'b> Network<'a, 'b> { .await .map(RadarrSerdeable::from), RadarrEvent::DeleteDownload(download_id) => self - .delete_download(download_id) + .delete_radarr_download(download_id) .await .map(RadarrSerdeable::from), RadarrEvent::DeleteIndexer(indexer_id) => self @@ -504,7 +504,7 @@ impl<'a, 'b> Network<'a, 'b> { .await } - async fn delete_download(&mut self, download_id: Option) -> Result<()> { + async fn delete_radarr_download(&mut self, download_id: Option) -> Result<()> { let event = RadarrEvent::DeleteDownload(None); let id = if let Some(dl_id) = download_id { dl_id diff --git a/src/network/radarr_network_tests.rs b/src/network/radarr_network_tests.rs index 6dc5809..d55ddd5 100644 --- a/src/network/radarr_network_tests.rs +++ b/src/network/radarr_network_tests.rs @@ -3231,7 +3231,7 @@ mod test { } #[tokio::test] - async fn test_handle_delete_download_event() { + async fn test_handle_delete_radarr_download_event() { let (async_server, app_arc, _server) = mock_servarr_api( RequestMethod::Delete, None, @@ -3260,7 +3260,7 @@ mod test { } #[tokio::test] - async fn test_handle_delete_download_event_uses_provided_id() { + async fn test_handle_delete_radarr_download_event_uses_provided_id() { 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 5fbdef0..8e858e1 100644 --- a/src/network/sonarr_network.rs +++ b/src/network/sonarr_network.rs @@ -32,6 +32,7 @@ mod sonarr_network_tests; pub enum SonarrEvent { ClearBlocklist, DeleteBlocklistItem(Option), + DeleteDownload(Option), GetAllIndexerSettings, GetBlocklist, GetDownloads, @@ -61,7 +62,7 @@ impl NetworkResource for SonarrEvent { SonarrEvent::DeleteBlocklistItem(_) => "/blocklist", SonarrEvent::GetAllIndexerSettings => "/config/indexer", SonarrEvent::GetBlocklist => "/blocklist?page=1&pageSize=10000", - SonarrEvent::GetDownloads => "/queue", + SonarrEvent::GetDownloads | SonarrEvent::DeleteDownload(_) => "/queue", SonarrEvent::GetEpisodes(_) | SonarrEvent::GetEpisodeDetails(_) => "/episode", SonarrEvent::GetHistory(_) | SonarrEvent::GetEpisodeHistory(_) => "/history", SonarrEvent::GetHostConfig | SonarrEvent::GetSecurityConfig => "/config/host", @@ -102,6 +103,10 @@ impl<'a, 'b> Network<'a, 'b> { .delete_sonarr_blocklist_item(blocklist_item_id) .await .map(SonarrSerdeable::from), + SonarrEvent::DeleteDownload(download_id) => self + .delete_sonarr_download(download_id) + .await + .map(SonarrSerdeable::from), SonarrEvent::GetBlocklist => self.get_sonarr_blocklist().await.map(SonarrSerdeable::from), SonarrEvent::GetDownloads => self.get_sonarr_downloads().await.map(SonarrSerdeable::from), SonarrEvent::GetEpisodes(series_id) => self @@ -230,6 +235,39 @@ impl<'a, 'b> Network<'a, 'b> { .await } + async fn delete_sonarr_download(&mut self, download_id: Option) -> Result<()> { + let event = SonarrEvent::DeleteDownload(None); + let id = if let Some(dl_id) = download_id { + dl_id + } else { + self + .app + .lock() + .await + .data + .sonarr_data + .downloads + .current_selection() + .id + }; + + info!("Deleting Sonarr download for download 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_all_sonarr_indexer_settings(&mut self) -> Result { info!("Fetching Sonarr indexer settings"); let event = SonarrEvent::GetAllIndexerSettings; diff --git a/src/network/sonarr_network_tests.rs b/src/network/sonarr_network_tests.rs index 12b0bbd..f6656ad 100644 --- a/src/network/sonarr_network_tests.rs +++ b/src/network/sonarr_network_tests.rs @@ -163,6 +163,13 @@ mod test { assert_str_eq!(event.resource(), "/history"); } + #[rstest] + fn test_resource_queue( + #[values(SonarrEvent::GetDownloads, SonarrEvent::DeleteDownload(None))] event: SonarrEvent, + ) { + assert_str_eq!(event.resource(), "/queue"); + } + #[rstest] fn test_resource_release( #[values( @@ -179,7 +186,6 @@ mod test { #[case(SonarrEvent::DeleteBlocklistItem(None), "/blocklist")] #[case(SonarrEvent::HealthCheck, "/health")] #[case(SonarrEvent::GetBlocklist, "/blocklist?page=1&pageSize=10000")] - #[case(SonarrEvent::GetDownloads, "/queue")] #[case(SonarrEvent::GetSeriesHistory(None), "/history/series")] #[case(SonarrEvent::GetLogs(Some(500)), "/log")] #[case(SonarrEvent::GetQualityProfiles, "/qualityprofile")] @@ -269,6 +275,57 @@ mod test { async_server.assert_async().await; } + #[tokio::test] + async fn test_handle_delete_sonarr_download_event() { + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Delete, + None, + None, + None, + SonarrEvent::DeleteDownload(None), + Some("/1"), + None, + ) + .await; + app_arc + .lock() + .await + .data + .sonarr_data + .downloads + .set_items(vec![download_record()]); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + assert!(network + .handle_sonarr_event(SonarrEvent::DeleteDownload(None)) + .await + .is_ok()); + + async_server.assert_async().await; + } + + #[tokio::test] + async fn test_handle_delete_sonarr_download_event_uses_provided_id() { + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Delete, + None, + None, + None, + SonarrEvent::DeleteDownload(None), + Some("/1"), + None, + ) + .await; + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + assert!(network + .handle_sonarr_event(SonarrEvent::DeleteDownload(Some(1))) + .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) { From 1a65a7f3e7061bb0c4b294b73a8d5dcd6c19ae82 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Thu, 21 Nov 2024 14:49:36 -0700 Subject: [PATCH 044/119] feat(cli): Support for deleting a download from Sonarr --- src/cli/sonarr/delete_command_handler.rs | 12 ++++ .../sonarr/delete_command_handler_tests.rs | 59 +++++++++++++++++++ 2 files changed, 71 insertions(+) diff --git a/src/cli/sonarr/delete_command_handler.rs b/src/cli/sonarr/delete_command_handler.rs index 2bf8991..1de70e2 100644 --- a/src/cli/sonarr/delete_command_handler.rs +++ b/src/cli/sonarr/delete_command_handler.rs @@ -27,6 +27,11 @@ pub enum SonarrDeleteCommand { )] blocklist_item_id: i64, }, + #[command(about = "Delete the specified download")] + Download { + #[arg(long, help = "The ID of the download to delete", required = true)] + download_id: i64, + }, } impl From for Command { @@ -63,6 +68,13 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, SonarrDeleteCommand> for SonarrDeleteComm .await?; serde_json::to_string_pretty(&resp)? } + SonarrDeleteCommand::Download { download_id } => { + let resp = self + .network + .handle_network_event((SonarrEvent::DeleteDownload(Some(download_id))).into()) + .await?; + serde_json::to_string_pretty(&resp)? + } }; Ok(resp) diff --git a/src/cli/sonarr/delete_command_handler_tests.rs b/src/cli/sonarr/delete_command_handler_tests.rs index 546f433..688f9c0 100644 --- a/src/cli/sonarr/delete_command_handler_tests.rs +++ b/src/cli/sonarr/delete_command_handler_tests.rs @@ -58,6 +58,39 @@ mod tests { assert_eq!(delete_command, expected_args); } } + + #[test] + fn test_delete_download_requires_arguments() { + let result = + Cli::command().try_get_matches_from(["managarr", "sonarr", "delete", "download"]); + + assert!(result.is_err()); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_delete_download_success() { + let expected_args = SonarrDeleteCommand::Download { download_id: 1 }; + + let result = Cli::try_parse_from([ + "managarr", + "sonarr", + "delete", + "download", + "--download-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 { @@ -107,5 +140,31 @@ mod tests { assert!(result.is_ok()); } + + #[tokio::test] + async fn test_handle_delete_download_command() { + let expected_download_id = 1; + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::( + SonarrEvent::DeleteDownload(Some(expected_download_id)).into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Sonarr(SonarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::default())); + let delete_download_command = SonarrDeleteCommand::Download { download_id: 1 }; + + let result = + SonarrDeleteCommandHandler::with(&app_arc, delete_download_command, &mut mock_network) + .handle() + .await; + + assert!(result.is_ok()); + } } } From 72cb334b6af6db61f960772e48af481aafd17c23 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Thu, 21 Nov 2024 16:19:53 -0700 Subject: [PATCH 045/119] feat(network): Support for deleting an indexer from Sonarr --- src/network/radarr_network.rs | 4 +-- src/network/radarr_network_tests.rs | 4 +-- src/network/sonarr_network.rs | 40 ++++++++++++++++++++- src/network/sonarr_network_tests.rs | 55 ++++++++++++++++++++++++++++- 4 files changed, 97 insertions(+), 6 deletions(-) diff --git a/src/network/radarr_network.rs b/src/network/radarr_network.rs index fa745a6..f02965a 100644 --- a/src/network/radarr_network.rs +++ b/src/network/radarr_network.rs @@ -160,7 +160,7 @@ impl<'a, 'b> Network<'a, 'b> { .await .map(RadarrSerdeable::from), RadarrEvent::DeleteIndexer(indexer_id) => self - .delete_indexer(indexer_id) + .delete_radarr_indexer(indexer_id) .await .map(RadarrSerdeable::from), RadarrEvent::DeleteMovie(params) => { @@ -537,7 +537,7 @@ impl<'a, 'b> Network<'a, 'b> { .await } - async fn delete_indexer(&mut self, indexer_id: Option) -> Result<()> { + async fn delete_radarr_indexer(&mut self, indexer_id: Option) -> Result<()> { let event = RadarrEvent::DeleteIndexer(None); let id = if let Some(i_id) = indexer_id { i_id diff --git a/src/network/radarr_network_tests.rs b/src/network/radarr_network_tests.rs index d55ddd5..e9dcc43 100644 --- a/src/network/radarr_network_tests.rs +++ b/src/network/radarr_network_tests.rs @@ -3282,7 +3282,7 @@ mod test { } #[tokio::test] - async fn test_handle_delete_indexer_event() { + async fn test_handle_delete_radarr_indexer_event() { let (async_server, app_arc, _server) = mock_servarr_api( RequestMethod::Delete, None, @@ -3311,7 +3311,7 @@ mod test { } #[tokio::test] - async fn test_handle_delete_indexer_event_uses_provided_id() { + async fn test_handle_delete_radarr_indexer_event_uses_provided_id() { 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 8e858e1..98b0ccb 100644 --- a/src/network/sonarr_network.rs +++ b/src/network/sonarr_network.rs @@ -33,6 +33,7 @@ pub enum SonarrEvent { ClearBlocklist, DeleteBlocklistItem(Option), DeleteDownload(Option), + DeleteIndexer(Option), GetAllIndexerSettings, GetBlocklist, GetDownloads, @@ -66,7 +67,7 @@ impl NetworkResource for SonarrEvent { SonarrEvent::GetEpisodes(_) | SonarrEvent::GetEpisodeDetails(_) => "/episode", SonarrEvent::GetHistory(_) | SonarrEvent::GetEpisodeHistory(_) => "/history", SonarrEvent::GetHostConfig | SonarrEvent::GetSecurityConfig => "/config/host", - SonarrEvent::GetIndexers => "/indexer", + SonarrEvent::GetIndexers | SonarrEvent::DeleteIndexer(_) => "/indexer", SonarrEvent::GetLogs(_) => "/log", SonarrEvent::GetQualityProfiles => "/qualityprofile", SonarrEvent::GetQueuedEvents => "/command", @@ -107,6 +108,10 @@ impl<'a, 'b> Network<'a, 'b> { .delete_sonarr_download(download_id) .await .map(SonarrSerdeable::from), + SonarrEvent::DeleteIndexer(indexer_id) => self + .delete_sonarr_indexer(indexer_id) + .await + .map(SonarrSerdeable::from), SonarrEvent::GetBlocklist => self.get_sonarr_blocklist().await.map(SonarrSerdeable::from), SonarrEvent::GetDownloads => self.get_sonarr_downloads().await.map(SonarrSerdeable::from), SonarrEvent::GetEpisodes(series_id) => self @@ -268,6 +273,39 @@ impl<'a, 'b> Network<'a, 'b> { .await } + async fn delete_sonarr_indexer(&mut self, indexer_id: Option) -> Result<()> { + let event = SonarrEvent::DeleteIndexer(None); + let id = if let Some(i_id) = indexer_id { + i_id + } else { + self + .app + .lock() + .await + .data + .sonarr_data + .indexers + .current_selection() + .id + }; + + info!("Deleting Sonarr indexer for indexer 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_all_sonarr_indexer_settings(&mut self) -> Result { info!("Fetching Sonarr indexer settings"); let event = SonarrEvent::GetAllIndexerSettings; diff --git a/src/network/sonarr_network_tests.rs b/src/network/sonarr_network_tests.rs index f6656ad..b1f7e24 100644 --- a/src/network/sonarr_network_tests.rs +++ b/src/network/sonarr_network_tests.rs @@ -151,7 +151,9 @@ mod test { } #[rstest] - fn test_resource_indexer(#[values(SonarrEvent::GetIndexers)] event: SonarrEvent) { + fn test_resource_indexer( + #[values(SonarrEvent::GetIndexers, SonarrEvent::DeleteIndexer(None))] event: SonarrEvent, + ) { assert_str_eq!(event.resource(), "/indexer"); } @@ -326,6 +328,57 @@ mod test { async_server.assert_async().await; } + #[tokio::test] + async fn test_handle_delete_sonarr_indexer_event() { + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Delete, + None, + None, + None, + SonarrEvent::DeleteIndexer(None), + Some("/1"), + None, + ) + .await; + app_arc + .lock() + .await + .data + .sonarr_data + .indexers + .set_items(vec![indexer()]); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + assert!(network + .handle_sonarr_event(SonarrEvent::DeleteIndexer(None)) + .await + .is_ok()); + + async_server.assert_async().await; + } + + #[tokio::test] + async fn test_handle_delete_sonarr_indexer_event_uses_provided_id() { + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Delete, + None, + None, + None, + SonarrEvent::DeleteIndexer(None), + Some("/1"), + None, + ) + .await; + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + assert!(network + .handle_sonarr_event(SonarrEvent::DeleteIndexer(Some(1))) + .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) { From f4c647342be093b243f160e1f169a6147c9c2e5f Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Thu, 21 Nov 2024 16:22:30 -0700 Subject: [PATCH 046/119] feat(cli): Support for deleting a Sonarr indexer --- src/cli/sonarr/delete_command_handler.rs | 12 ++++ .../sonarr/delete_command_handler_tests.rs | 58 +++++++++++++++++++ 2 files changed, 70 insertions(+) diff --git a/src/cli/sonarr/delete_command_handler.rs b/src/cli/sonarr/delete_command_handler.rs index 1de70e2..7d958b3 100644 --- a/src/cli/sonarr/delete_command_handler.rs +++ b/src/cli/sonarr/delete_command_handler.rs @@ -32,6 +32,11 @@ pub enum SonarrDeleteCommand { #[arg(long, help = "The ID of the download to delete", required = true)] download_id: i64, }, + #[command(about = "Delete the indexer with the given ID")] + Indexer { + #[arg(long, help = "The ID of the indexer to delete", required = true)] + indexer_id: i64, + }, } impl From for Command { @@ -75,6 +80,13 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, SonarrDeleteCommand> for SonarrDeleteComm .await?; serde_json::to_string_pretty(&resp)? } + SonarrDeleteCommand::Indexer { indexer_id } => { + let resp = self + .network + .handle_network_event((SonarrEvent::DeleteIndexer(Some(indexer_id))).into()) + .await?; + serde_json::to_string_pretty(&resp)? + } }; Ok(resp) diff --git a/src/cli/sonarr/delete_command_handler_tests.rs b/src/cli/sonarr/delete_command_handler_tests.rs index 688f9c0..378d2aa 100644 --- a/src/cli/sonarr/delete_command_handler_tests.rs +++ b/src/cli/sonarr/delete_command_handler_tests.rs @@ -91,6 +91,38 @@ mod tests { assert_eq!(delete_command, expected_args); } } + + #[test] + fn test_delete_indexer_requires_arguments() { + let result = Cli::command().try_get_matches_from(["managarr", "sonarr", "delete", "indexer"]); + + assert!(result.is_err()); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_delete_indexer_success() { + let expected_args = SonarrDeleteCommand::Indexer { indexer_id: 1 }; + + let result = Cli::try_parse_from([ + "managarr", + "sonarr", + "delete", + "indexer", + "--indexer-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 { @@ -166,5 +198,31 @@ mod tests { assert!(result.is_ok()); } + + #[tokio::test] + async fn test_handle_delete_indexer_command() { + let expected_indexer_id = 1; + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::( + SonarrEvent::DeleteIndexer(Some(expected_indexer_id)).into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Sonarr(SonarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::default())); + let delete_indexer_command = SonarrDeleteCommand::Indexer { indexer_id: 1 }; + + let result = + SonarrDeleteCommandHandler::with(&app_arc, delete_indexer_command, &mut mock_network) + .handle() + .await; + + assert!(result.is_ok()); + } } } From 16538a31583173910e3f5df59df6c37925258798 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Thu, 21 Nov 2024 16:37:23 -0700 Subject: [PATCH 047/119] feat(network): Support for fetching all Sonarr root folders --- .../library/add_movie_handler_tests.rs | 5 +- .../root_folders_handler_tests.rs | 6 +-- src/models/radarr_models.rs | 20 +------ src/models/radarr_models_tests.rs | 4 +- src/models/servarr_data/radarr/modals.rs | 4 +- .../servarr_data/radarr/modals_tests.rs | 4 +- src/models/servarr_data/radarr/radarr_data.rs | 4 +- src/models/servarr_data/sonarr/sonarr_data.rs | 4 +- .../servarr_data/sonarr/sonarr_data_tests.rs | 1 + src/models/servarr_models.rs | 18 +++++++ src/models/sonarr_models.rs | 4 +- src/models/sonarr_models_tests.rs | 15 +++++- src/network/radarr_network.rs | 11 ++-- src/network/radarr_network_tests.rs | 2 +- src/network/sonarr_network.rs | 24 ++++++++- src/network/sonarr_network_tests.rs | 52 ++++++++++++++++++- src/ui/radarr_ui/mod.rs | 3 +- src/ui/radarr_ui/root_folders/mod.rs | 2 +- 18 files changed, 138 insertions(+), 45 deletions(-) diff --git a/src/handlers/radarr_handlers/library/add_movie_handler_tests.rs b/src/handlers/radarr_handlers/library/add_movie_handler_tests.rs index 7da2d8f..c0797ec 100644 --- a/src/handlers/radarr_handlers/library/add_movie_handler_tests.rs +++ b/src/handlers/radarr_handlers/library/add_movie_handler_tests.rs @@ -8,10 +8,9 @@ mod tests { use crate::event::Key; use crate::handlers::radarr_handlers::library::add_movie_handler::AddMovieHandler; use crate::handlers::KeyEventHandler; - use crate::models::radarr_models::{ - AddMovieSearchResult, MinimumAvailability, Monitor, RootFolder, - }; + use crate::models::radarr_models::{AddMovieSearchResult, MinimumAvailability, Monitor}; use crate::models::servarr_data::radarr::radarr_data::{ActiveRadarrBlock, ADD_MOVIE_BLOCKS}; + use crate::models::servarr_models::RootFolder; use crate::models::HorizontallyScrollableText; mod test_handle_scroll_up_and_down { diff --git a/src/handlers/radarr_handlers/root_folders/root_folders_handler_tests.rs b/src/handlers/radarr_handlers/root_folders/root_folders_handler_tests.rs index 8a62dde..fc9bd65 100644 --- a/src/handlers/radarr_handlers/root_folders/root_folders_handler_tests.rs +++ b/src/handlers/radarr_handlers/root_folders/root_folders_handler_tests.rs @@ -8,14 +8,14 @@ mod tests { use crate::event::Key; use crate::handlers::radarr_handlers::root_folders::RootFoldersHandler; use crate::handlers::KeyEventHandler; - use crate::models::radarr_models::RootFolder; use crate::models::servarr_data::radarr::radarr_data::{ActiveRadarrBlock, ROOT_FOLDERS_BLOCKS}; + use crate::models::servarr_models::RootFolder; use crate::models::HorizontallyScrollableText; mod test_handle_scroll_up_and_down { use rstest::rstest; - use crate::models::radarr_models::RootFolder; + use crate::models::servarr_models::RootFolder; use crate::{simple_stateful_iterable_vec, test_iterable_scroll}; use super::*; @@ -63,7 +63,7 @@ mod tests { use pretty_assertions::assert_eq; - use crate::models::radarr_models::RootFolder; + use crate::models::servarr_models::RootFolder; use crate::{extended_stateful_iterable_vec, test_iterable_home_and_end}; use super::*; diff --git a/src/models/radarr_models.rs b/src/models/radarr_models.rs index 286f3ee..283eeef 100644 --- a/src/models/radarr_models.rs +++ b/src/models/radarr_models.rs @@ -11,7 +11,7 @@ use crate::{models::HorizontallyScrollableText, serde_enum_from}; use super::servarr_models::{ HostConfig, Indexer, Language, LogResponse, QualityProfile, QualityWrapper, QueueEvent, Release, - SecurityConfig, + RootFolder, SecurityConfig, }; use super::{EnumDisplayStyle, Serdeable}; @@ -440,18 +440,6 @@ pub struct ReleaseDownloadBody { pub movie_id: i64, } -#[derive(Default, Serialize, Deserialize, Debug, Clone, Eq, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct RootFolder { - #[serde(deserialize_with = "super::from_i64")] - pub id: i64, - pub path: String, - pub accessible: bool, - #[serde(deserialize_with = "super::from_i64")] - pub free_space: i64, - pub unmapped_folders: Option>, -} - #[derive(Default, Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct SystemStatus { @@ -504,12 +492,6 @@ impl Display for TaskName { } } -#[derive(Serialize, Deserialize, Default, Debug, Clone, Eq, PartialEq)] -pub struct UnmappedFolder { - pub name: String, - pub path: String, -} - #[derive(Default, Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct Update { diff --git a/src/models/radarr_models_tests.rs b/src/models/radarr_models_tests.rs index 8cb1643..eb07c6b 100644 --- a/src/models/radarr_models_tests.rs +++ b/src/models/radarr_models_tests.rs @@ -8,9 +8,9 @@ mod tests { AddMovieSearchResult, BlocklistItem, BlocklistResponse, Collection, Credit, DiskSpace, DownloadRecord, DownloadsResponse, Indexer, IndexerSettings, IndexerTestResult, MinimumAvailability, Monitor, Movie, MovieHistoryItem, QualityProfile, RadarrSerdeable, - Release, RootFolder, SystemStatus, Tag, Task, TaskName, Update, + Release, SystemStatus, Tag, Task, TaskName, Update, }, - servarr_models::{HostConfig, Log, LogResponse, QueueEvent, SecurityConfig}, + servarr_models::{HostConfig, Log, LogResponse, QueueEvent, RootFolder, SecurityConfig}, EnumDisplayStyle, Serdeable, }; diff --git a/src/models/servarr_data/radarr/modals.rs b/src/models/servarr_data/radarr/modals.rs index 8cfce95..3f73e3e 100644 --- a/src/models/servarr_data/radarr/modals.rs +++ b/src/models/servarr_data/radarr/modals.rs @@ -1,10 +1,10 @@ use strum::IntoEnumIterator; use crate::models::radarr_models::{ - Collection, Credit, MinimumAvailability, Monitor, Movie, MovieHistoryItem, RootFolder, + Collection, Credit, MinimumAvailability, Monitor, Movie, MovieHistoryItem, }; use crate::models::servarr_data::radarr::radarr_data::RadarrData; -use crate::models::servarr_models::{Indexer, Release}; +use crate::models::servarr_models::{Indexer, Release, RootFolder}; use crate::models::stateful_list::StatefulList; use crate::models::stateful_table::StatefulTable; use crate::models::{HorizontallyScrollableText, ScrollableText}; diff --git a/src/models/servarr_data/radarr/modals_tests.rs b/src/models/servarr_data/radarr/modals_tests.rs index 5da0298..62faa71 100644 --- a/src/models/servarr_data/radarr/modals_tests.rs +++ b/src/models/servarr_data/radarr/modals_tests.rs @@ -1,12 +1,12 @@ #[cfg(test)] mod test { - use crate::models::radarr_models::{Collection, MinimumAvailability, Monitor, Movie, RootFolder}; + use crate::models::radarr_models::{Collection, MinimumAvailability, Monitor, Movie}; use crate::models::servarr_data::radarr::modals::{ AddMovieModal, EditCollectionModal, EditIndexerModal, EditMovieModal, }; use crate::models::servarr_data::radarr::radarr_data::radarr_test_utils::utils::create_test_radarr_data; use crate::models::servarr_data::radarr::radarr_data::RadarrData; - use crate::models::servarr_models::{Indexer, IndexerField}; + use crate::models::servarr_models::{Indexer, IndexerField, RootFolder}; use crate::models::stateful_table::StatefulTable; use bimap::BiMap; use pretty_assertions::{assert_eq, assert_str_eq}; diff --git a/src/models/servarr_data/radarr/radarr_data.rs b/src/models/servarr_data/radarr/radarr_data.rs index e3d4a7c..4f7e6a4 100644 --- a/src/models/servarr_data/radarr/radarr_data.rs +++ b/src/models/servarr_data/radarr/radarr_data.rs @@ -7,13 +7,13 @@ use crate::app::radarr::radarr_context_clues::{ }; use crate::models::radarr_models::{ AddMovieSearchResult, BlocklistItem, Collection, CollectionMovie, DiskSpace, DownloadRecord, - IndexerSettings, Movie, RootFolder, Task, + IndexerSettings, Movie, Task, }; use crate::models::servarr_data::radarr::modals::{ AddMovieModal, EditCollectionModal, EditIndexerModal, EditMovieModal, IndexerTestResultModalItem, MovieDetailsModal, }; -use crate::models::servarr_models::{Indexer, QueueEvent}; +use crate::models::servarr_models::{Indexer, QueueEvent, RootFolder}; use crate::models::stateful_list::StatefulList; use crate::models::stateful_table::StatefulTable; use crate::models::{ diff --git a/src/models/servarr_data/sonarr/sonarr_data.rs b/src/models/servarr_data/sonarr/sonarr_data.rs index c3434ce..cd274d2 100644 --- a/src/models/servarr_data/sonarr/sonarr_data.rs +++ b/src/models/servarr_data/sonarr/sonarr_data.rs @@ -3,7 +3,7 @@ use chrono::{DateTime, Utc}; use strum::EnumIter; use crate::models::{ - servarr_models::{Indexer, QueueEvent}, + servarr_models::{Indexer, QueueEvent, RootFolder}, sonarr_models::{ BlocklistItem, DownloadRecord, IndexerSettings, Season, Series, SonarrHistoryItem, }, @@ -27,6 +27,7 @@ pub struct SonarrData { pub logs: StatefulList, pub quality_profile_map: BiMap, pub queued_events: StatefulTable, + pub root_folders: StatefulTable, pub seasons: StatefulTable, pub season_details_modal: Option, pub series: StatefulTable, @@ -46,6 +47,7 @@ impl Default for SonarrData { logs: StatefulList::default(), quality_profile_map: BiMap::new(), queued_events: StatefulTable::default(), + root_folders: StatefulTable::default(), seasons: StatefulTable::default(), season_details_modal: None, series: StatefulTable::default(), diff --git a/src/models/servarr_data/sonarr/sonarr_data_tests.rs b/src/models/servarr_data/sonarr/sonarr_data_tests.rs index 90051cb..60fecc2 100644 --- a/src/models/servarr_data/sonarr/sonarr_data_tests.rs +++ b/src/models/servarr_data/sonarr/sonarr_data_tests.rs @@ -42,6 +42,7 @@ mod tests { assert!(sonarr_data.logs.is_empty()); assert!(sonarr_data.quality_profile_map.is_empty()); assert!(sonarr_data.queued_events.is_empty()); + assert!(sonarr_data.root_folders.is_empty()); assert!(sonarr_data.seasons.is_empty()); assert!(sonarr_data.season_details_modal.is_none()); assert!(sonarr_data.series.is_empty()); diff --git a/src/models/servarr_models.rs b/src/models/servarr_models.rs index 82392d7..12c5234 100644 --- a/src/models/servarr_models.rs +++ b/src/models/servarr_models.rs @@ -197,6 +197,18 @@ pub struct Release { pub quality: QualityWrapper, } +#[derive(Default, Serialize, Deserialize, Debug, Clone, Eq, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct RootFolder { + #[serde(deserialize_with = "super::from_i64")] + pub id: i64, + pub path: String, + pub accessible: bool, + #[serde(deserialize_with = "super::from_i64")] + pub free_space: i64, + pub unmapped_folders: Option>, +} + #[derive(Default, Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct SecurityConfig { @@ -210,3 +222,9 @@ pub struct SecurityConfig { pub api_key: String, pub certificate_validation: CertificateValidation, } + +#[derive(Serialize, Deserialize, Default, Debug, Clone, Eq, PartialEq)] +pub struct UnmappedFolder { + pub name: String, + pub path: String, +} diff --git a/src/models/sonarr_models.rs b/src/models/sonarr_models.rs index aa22e2b..edba686 100644 --- a/src/models/sonarr_models.rs +++ b/src/models/sonarr_models.rs @@ -12,7 +12,7 @@ use crate::serde_enum_from; use super::{ servarr_models::{ HostConfig, Indexer, Language, LogResponse, QualityProfile, QualityWrapper, QueueEvent, - Release, SecurityConfig, + Release, RootFolder, SecurityConfig, }, EnumDisplayStyle, HorizontallyScrollableText, Serdeable, }; @@ -402,6 +402,7 @@ pub enum SonarrSerdeable { QualityProfiles(Vec), QueueEvents(Vec), Releases(Vec), + RootFolders(Vec), SecurityConfig(SecurityConfig), SeriesVec(Vec), Series(Series), @@ -436,6 +437,7 @@ serde_enum_from!( QualityProfiles(Vec), QueueEvents(Vec), Releases(Vec), + RootFolders(Vec), SecurityConfig(SecurityConfig), SeriesVec(Vec), Series(Series), diff --git a/src/models/sonarr_models_tests.rs b/src/models/sonarr_models_tests.rs index b65779f..8bee1a1 100644 --- a/src/models/sonarr_models_tests.rs +++ b/src/models/sonarr_models_tests.rs @@ -5,7 +5,8 @@ mod tests { use crate::models::{ servarr_models::{ - HostConfig, Indexer, Log, LogResponse, QualityProfile, QueueEvent, Release, SecurityConfig, + HostConfig, Indexer, Log, LogResponse, QualityProfile, QueueEvent, Release, RootFolder, + SecurityConfig, }, sonarr_models::{ BlocklistItem, BlocklistResponse, DownloadRecord, DownloadsResponse, Episode, @@ -343,6 +344,18 @@ mod tests { assert_eq!(sonarr_serdeable, SonarrSerdeable::Releases(releases)); } + #[test] + fn test_sonarr_serdeable_from_root_folders() { + let root_folders = vec![RootFolder { + id: 1, + ..RootFolder::default() + }]; + + let sonarr_serdeable: SonarrSerdeable = root_folders.clone().into(); + + assert_eq!(sonarr_serdeable, SonarrSerdeable::RootFolders(root_folders)); + } + #[test] fn test_sonarr_serdeable_from_security_config() { let security_config = SecurityConfig { diff --git a/src/network/radarr_network.rs b/src/network/radarr_network.rs index f02965a..f7599a0 100644 --- a/src/network/radarr_network.rs +++ b/src/network/radarr_network.rs @@ -11,7 +11,7 @@ use crate::models::radarr_models::{ CollectionMovie, CommandBody, Credit, CreditType, DeleteMovieParams, DiskSpace, DownloadRecord, DownloadsResponse, EditCollectionParams, EditIndexerParams, EditMovieParams, IndexerSettings, IndexerTestResult, Movie, MovieCommandBody, MovieHistoryItem, RadarrSerdeable, - ReleaseDownloadBody, RootFolder, SystemStatus, Tag, Task, TaskName, Update, + ReleaseDownloadBody, SystemStatus, Tag, Task, TaskName, Update, }; use crate::models::servarr_data::radarr::modals::{ AddMovieModal, EditCollectionModal, EditIndexerModal, EditMovieModal, IndexerTestResultModalItem, @@ -19,7 +19,7 @@ use crate::models::servarr_data::radarr::modals::{ }; use crate::models::servarr_data::radarr::radarr_data::ActiveRadarrBlock; use crate::models::servarr_models::{ - HostConfig, Indexer, LogResponse, QualityProfile, QueueEvent, Release, SecurityConfig, + HostConfig, Indexer, LogResponse, QualityProfile, QueueEvent, Release, RootFolder, SecurityConfig, }; use crate::models::stateful_table::StatefulTable; use crate::models::{HorizontallyScrollableText, Route, Scrollable, ScrollableText}; @@ -228,7 +228,10 @@ impl<'a, 'b> Network<'a, 'b> { .get_movie_releases(movie_id) .await .map(RadarrSerdeable::from), - RadarrEvent::GetRootFolders => self.get_root_folders().await.map(RadarrSerdeable::from), + RadarrEvent::GetRootFolders => self + .get_radarr_root_folders() + .await + .map(RadarrSerdeable::from), RadarrEvent::GetSecurityConfig => self .get_radarr_security_config() .await @@ -1785,7 +1788,7 @@ impl<'a, 'b> Network<'a, 'b> { .await } - async fn get_root_folders(&mut self) -> Result> { + async fn get_radarr_root_folders(&mut self) -> Result> { info!("Fetching Radarr root folders"); let event = RadarrEvent::GetRootFolders; diff --git a/src/network/radarr_network_tests.rs b/src/network/radarr_network_tests.rs index e9dcc43..8b83ad5 100644 --- a/src/network/radarr_network_tests.rs +++ b/src/network/radarr_network_tests.rs @@ -2872,7 +2872,7 @@ mod test { } #[tokio::test] - async fn test_handle_get_root_folders_event() { + async fn test_handle_get_radarr_root_folders_event() { let root_folder_json = json!([{ "id": 1, "path": "/nfs", diff --git a/src/network/sonarr_network.rs b/src/network/sonarr_network.rs index 98b0ccb..a5a1c7b 100644 --- a/src/network/sonarr_network.rs +++ b/src/network/sonarr_network.rs @@ -10,7 +10,8 @@ use crate::{ sonarr_data::ActiveSonarrBlock, }, servarr_models::{ - HostConfig, Indexer, LogResponse, QualityProfile, QueueEvent, Release, SecurityConfig, + HostConfig, Indexer, LogResponse, QualityProfile, QueueEvent, Release, RootFolder, + SecurityConfig, }, sonarr_models::{ BlocklistResponse, DownloadRecord, DownloadsResponse, Episode, IndexerSettings, Series, @@ -46,6 +47,7 @@ pub enum SonarrEvent { GetLogs(Option), GetQualityProfiles, GetQueuedEvents, + GetRootFolders, GetEpisodeReleases(Option), GetSeasonReleases(Option<(i64, i64)>), GetSecurityConfig, @@ -71,6 +73,7 @@ impl NetworkResource for SonarrEvent { SonarrEvent::GetLogs(_) => "/log", SonarrEvent::GetQualityProfiles => "/qualityprofile", SonarrEvent::GetQueuedEvents => "/command", + SonarrEvent::GetRootFolders => "/rootfolder", SonarrEvent::GetSeasonReleases(_) | SonarrEvent::GetEpisodeReleases(_) => "/release", SonarrEvent::GetSeriesHistory(_) => "/history/series", SonarrEvent::GetStatus => "/system/status", @@ -147,6 +150,10 @@ impl<'a, 'b> Network<'a, 'b> { .get_queued_sonarr_events() .await .map(SonarrSerdeable::from), + SonarrEvent::GetRootFolders => self + .get_sonarr_root_folders() + .await + .map(SonarrSerdeable::from), SonarrEvent::GetEpisodeReleases(params) => self .get_episode_releases(params) .await @@ -746,6 +753,21 @@ impl<'a, 'b> Network<'a, 'b> { .await } + async fn get_sonarr_root_folders(&mut self) -> Result> { + info!("Fetching Sonarr root folders"); + let event = SonarrEvent::GetRootFolders; + + let request_props = self + .request_props_from(event, RequestMethod::Get, None::<()>, None, None) + .await; + + self + .handle_request::<(), Vec>(request_props, |root_folders, mut app| { + app.data.sonarr_data.root_folders.set_items(root_folders); + }) + .await + } + async fn get_episode_releases(&mut self, episode_id: Option) -> Result> { let event = SonarrEvent::GetEpisodeReleases(None); let id = self.extract_episode_id(episode_id).await; diff --git a/src/network/sonarr_network_tests.rs b/src/network/sonarr_network_tests.rs index b1f7e24..7da3f0a 100644 --- a/src/network/sonarr_network_tests.rs +++ b/src/network/sonarr_network_tests.rs @@ -18,7 +18,7 @@ mod test { use crate::models::servarr_data::sonarr::sonarr_data::ActiveSonarrBlock; use crate::models::servarr_models::{ HostConfig, Indexer, IndexerField, Language, LogResponse, Quality, QualityProfile, - QualityWrapper, QueueEvent, Release, SecurityConfig, + QualityWrapper, QueueEvent, Release, RootFolder, SecurityConfig, }; use crate::models::sonarr_models::SystemStatus; use crate::models::sonarr_models::{ @@ -172,6 +172,11 @@ mod test { assert_str_eq!(event.resource(), "/queue"); } + #[rstest] + fn test_resource_root_folder(#[values(SonarrEvent::GetRootFolders)] event: SonarrEvent) { + assert_str_eq!(event.resource(), "/rootfolder"); + } + #[rstest] fn test_resource_release( #[values( @@ -2148,6 +2153,41 @@ mod test { } } + #[tokio::test] + async fn test_handle_get_sonarr_root_folders_event() { + let root_folder_json = json!([{ + "id": 1, + "path": "/nfs", + "accessible": true, + "freeSpace": 219902325555200u64, + }]); + let response: Vec = serde_json::from_value(root_folder_json.clone()).unwrap(); + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(root_folder_json), + None, + SonarrEvent::GetRootFolders, + None, + None, + ) + .await; + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + if let SonarrSerdeable::RootFolders(root_folders) = network + .handle_sonarr_event(SonarrEvent::GetRootFolders) + .await + .unwrap() + { + async_server.assert_async().await; + assert_eq!( + app_arc.lock().await.data.sonarr_data.root_folders.items, + vec![root_folder()] + ); + assert_eq!(root_folders, response); + } + } + #[tokio::test] async fn test_handle_get_episode_releases_event() { let release_json = json!([{ @@ -3839,4 +3879,14 @@ mod test { quality: quality_wrapper(), } } + + fn root_folder() -> RootFolder { + RootFolder { + id: 1, + path: "/nfs".to_owned(), + accessible: true, + free_space: 219902325555200, + unmapped_folders: None, + } + } } diff --git a/src/ui/radarr_ui/mod.rs b/src/ui/radarr_ui/mod.rs index 8f58912..ddc2422 100644 --- a/src/ui/radarr_ui/mod.rs +++ b/src/ui/radarr_ui/mod.rs @@ -9,8 +9,9 @@ use ratatui::Frame; use crate::app::App; use crate::logos::RADARR_LOGO; -use crate::models::radarr_models::{DiskSpace, DownloadRecord, Movie, RootFolder}; +use crate::models::radarr_models::{DiskSpace, DownloadRecord, Movie}; use crate::models::servarr_data::radarr::radarr_data::RadarrData; +use crate::models::servarr_models::RootFolder; use crate::models::Route; use crate::ui::draw_tabs; use crate::ui::radarr_ui::blocklist::BlocklistUi; diff --git a/src/ui/radarr_ui/root_folders/mod.rs b/src/ui/radarr_ui/root_folders/mod.rs index bff2797..381b37c 100644 --- a/src/ui/radarr_ui/root_folders/mod.rs +++ b/src/ui/radarr_ui/root_folders/mod.rs @@ -3,8 +3,8 @@ use ratatui::widgets::{Cell, Row}; use ratatui::Frame; use crate::app::App; -use crate::models::radarr_models::RootFolder; use crate::models::servarr_data::radarr::radarr_data::{ActiveRadarrBlock, ROOT_FOLDERS_BLOCKS}; +use crate::models::servarr_models::RootFolder; use crate::models::Route; use crate::ui::styles::ManagarrStyle; use crate::ui::utils::layout_block_top_border; From bb1c08277e5b45abd1df3c5e32498529a4a05275 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Thu, 21 Nov 2024 16:39:20 -0700 Subject: [PATCH 048/119] feat(cli): Support for fetching all Sonarr root folders --- src/cli/sonarr/list_command_handler.rs | 9 +++++++++ src/cli/sonarr/list_command_handler_tests.rs | 4 +++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/src/cli/sonarr/list_command_handler.rs b/src/cli/sonarr/list_command_handler.rs index 697292b..92ec8c9 100644 --- a/src/cli/sonarr/list_command_handler.rs +++ b/src/cli/sonarr/list_command_handler.rs @@ -62,6 +62,8 @@ pub enum SonarrListCommand { #[command(about = "List all queued events")] QueuedEvents, #[command(about = "List all series in your Sonarr library")] + #[command(about = "List all root folders in Sonarr")] + RootFolders, Series, #[command(about = "Fetch all history events for the series with the given ID")] SeriesHistory { @@ -174,6 +176,13 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, SonarrListCommand> for SonarrListCommandH .await?; serde_json::to_string_pretty(&resp)? } + SonarrListCommand::RootFolders => { + let resp = self + .network + .handle_network_event((SonarrEvent::GetRootFolders).into()) + .await?; + serde_json::to_string_pretty(&resp)? + } SonarrListCommand::Series => { let resp = self .network diff --git a/src/cli/sonarr/list_command_handler_tests.rs b/src/cli/sonarr/list_command_handler_tests.rs index e0d2e8a..b8ed298 100644 --- a/src/cli/sonarr/list_command_handler_tests.rs +++ b/src/cli/sonarr/list_command_handler_tests.rs @@ -30,7 +30,8 @@ mod tests { "downloads", "quality-profiles", "indexers", - "queued-events" + "queued-events", + "root-folders" )] subcommand: &str, ) { @@ -199,6 +200,7 @@ mod tests { #[case(SonarrListCommand::Indexers, SonarrEvent::GetIndexers)] #[case(SonarrListCommand::QualityProfiles, SonarrEvent::GetQualityProfiles)] #[case(SonarrListCommand::QueuedEvents, SonarrEvent::GetQueuedEvents)] + #[case(SonarrListCommand::RootFolders, SonarrEvent::GetRootFolders)] #[case(SonarrListCommand::Series, SonarrEvent::ListSeries)] #[tokio::test] async fn test_handle_list_command( From 1227796e78aa50eec7ad2fd4a1ad75086c213bef Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Thu, 21 Nov 2024 16:43:38 -0700 Subject: [PATCH 049/119] feat(network): Support for deleting a Sonarr root folder --- src/network/radarr_network.rs | 4 +-- src/network/radarr_network_tests.rs | 4 +-- src/network/sonarr_network.rs | 40 ++++++++++++++++++++- src/network/sonarr_network_tests.rs | 55 ++++++++++++++++++++++++++++- 4 files changed, 97 insertions(+), 6 deletions(-) diff --git a/src/network/radarr_network.rs b/src/network/radarr_network.rs index f7599a0..0b9e087 100644 --- a/src/network/radarr_network.rs +++ b/src/network/radarr_network.rs @@ -167,7 +167,7 @@ impl<'a, 'b> Network<'a, 'b> { self.delete_movie(params).await.map(RadarrSerdeable::from) } RadarrEvent::DeleteRootFolder(root_folder_id) => self - .delete_root_folder(root_folder_id) + .delete_radarr_root_folder(root_folder_id) .await .map(RadarrSerdeable::from), RadarrEvent::DeleteTag(tag_id) => self.delete_tag(tag_id).await.map(RadarrSerdeable::from), @@ -618,7 +618,7 @@ impl<'a, 'b> Network<'a, 'b> { resp } - async fn delete_root_folder(&mut self, root_folder_id: Option) -> Result<()> { + async fn delete_radarr_root_folder(&mut self, root_folder_id: Option) -> Result<()> { let event = RadarrEvent::DeleteRootFolder(None); let id = if let Some(rf_id) = root_folder_id { rf_id diff --git a/src/network/radarr_network_tests.rs b/src/network/radarr_network_tests.rs index 8b83ad5..b1f6219 100644 --- a/src/network/radarr_network_tests.rs +++ b/src/network/radarr_network_tests.rs @@ -3333,7 +3333,7 @@ mod test { } #[tokio::test] - async fn test_handle_delete_root_folder_event() { + async fn test_handle_delete_radarr_root_folder_event() { let (async_server, app_arc, _server) = mock_servarr_api( RequestMethod::Delete, None, @@ -3362,7 +3362,7 @@ mod test { } #[tokio::test] - async fn test_handle_delete_root_folder_event_uses_provided_id() { + async fn test_handle_delete_radarr_root_folder_event_uses_provided_id() { 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 a5a1c7b..b51406d 100644 --- a/src/network/sonarr_network.rs +++ b/src/network/sonarr_network.rs @@ -35,6 +35,7 @@ pub enum SonarrEvent { DeleteBlocklistItem(Option), DeleteDownload(Option), DeleteIndexer(Option), + DeleteRootFolder(Option), GetAllIndexerSettings, GetBlocklist, GetDownloads, @@ -73,7 +74,7 @@ impl NetworkResource for SonarrEvent { SonarrEvent::GetLogs(_) => "/log", SonarrEvent::GetQualityProfiles => "/qualityprofile", SonarrEvent::GetQueuedEvents => "/command", - SonarrEvent::GetRootFolders => "/rootfolder", + SonarrEvent::GetRootFolders | SonarrEvent::DeleteRootFolder(_) => "/rootfolder", SonarrEvent::GetSeasonReleases(_) | SonarrEvent::GetEpisodeReleases(_) => "/release", SonarrEvent::GetSeriesHistory(_) => "/history/series", SonarrEvent::GetStatus => "/system/status", @@ -115,6 +116,10 @@ impl<'a, 'b> Network<'a, 'b> { .delete_sonarr_indexer(indexer_id) .await .map(SonarrSerdeable::from), + SonarrEvent::DeleteRootFolder(root_folder_id) => self + .delete_sonarr_root_folder(root_folder_id) + .await + .map(SonarrSerdeable::from), SonarrEvent::GetBlocklist => self.get_sonarr_blocklist().await.map(SonarrSerdeable::from), SonarrEvent::GetDownloads => self.get_sonarr_downloads().await.map(SonarrSerdeable::from), SonarrEvent::GetEpisodes(series_id) => self @@ -313,6 +318,39 @@ impl<'a, 'b> Network<'a, 'b> { .await } + async fn delete_sonarr_root_folder(&mut self, root_folder_id: Option) -> Result<()> { + let event = SonarrEvent::DeleteRootFolder(None); + let id = if let Some(rf_id) = root_folder_id { + rf_id + } else { + self + .app + .lock() + .await + .data + .sonarr_data + .root_folders + .current_selection() + .id + }; + + info!("Deleting Sonarr root folder for folder 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_all_sonarr_indexer_settings(&mut self) -> Result { info!("Fetching Sonarr indexer settings"); let event = SonarrEvent::GetAllIndexerSettings; diff --git a/src/network/sonarr_network_tests.rs b/src/network/sonarr_network_tests.rs index 7da3f0a..e2c026a 100644 --- a/src/network/sonarr_network_tests.rs +++ b/src/network/sonarr_network_tests.rs @@ -173,7 +173,9 @@ mod test { } #[rstest] - fn test_resource_root_folder(#[values(SonarrEvent::GetRootFolders)] event: SonarrEvent) { + fn test_resource_root_folder( + #[values(SonarrEvent::GetRootFolders, SonarrEvent::DeleteRootFolder(None))] event: SonarrEvent, + ) { assert_str_eq!(event.resource(), "/rootfolder"); } @@ -384,6 +386,57 @@ mod test { async_server.assert_async().await; } + #[tokio::test] + async fn test_handle_delete_sonarr_root_folder_event() { + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Delete, + None, + None, + None, + SonarrEvent::DeleteRootFolder(None), + Some("/1"), + None, + ) + .await; + app_arc + .lock() + .await + .data + .sonarr_data + .root_folders + .set_items(vec![root_folder()]); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + assert!(network + .handle_sonarr_event(SonarrEvent::DeleteRootFolder(None)) + .await + .is_ok()); + + async_server.assert_async().await; + } + + #[tokio::test] + async fn test_handle_delete_sonarr_root_folder_event_uses_provided_id() { + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Delete, + None, + None, + None, + SonarrEvent::DeleteRootFolder(None), + Some("/1"), + None, + ) + .await; + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + assert!(network + .handle_sonarr_event(SonarrEvent::DeleteRootFolder(Some(1))) + .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) { From b24e3bf9db706244a0af6870816ea37f088fc5c1 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Thu, 21 Nov 2024 16:46:00 -0700 Subject: [PATCH 050/119] feat(cli): Support for deleting a root folder from Sonarr --- src/cli/sonarr/delete_command_handler.rs | 12 ++++ .../sonarr/delete_command_handler_tests.rs | 59 +++++++++++++++++++ 2 files changed, 71 insertions(+) diff --git a/src/cli/sonarr/delete_command_handler.rs b/src/cli/sonarr/delete_command_handler.rs index 7d958b3..0e1cd55 100644 --- a/src/cli/sonarr/delete_command_handler.rs +++ b/src/cli/sonarr/delete_command_handler.rs @@ -37,6 +37,11 @@ pub enum SonarrDeleteCommand { #[arg(long, help = "The ID of the indexer to delete", required = true)] indexer_id: i64, }, + #[command(about = "Delete the root folder with the given ID")] + RootFolder { + #[arg(long, help = "The ID of the root folder to delete", required = true)] + root_folder_id: i64, + }, } impl From for Command { @@ -87,6 +92,13 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, SonarrDeleteCommand> for SonarrDeleteComm .await?; serde_json::to_string_pretty(&resp)? } + SonarrDeleteCommand::RootFolder { root_folder_id } => { + let resp = self + .network + .handle_network_event((SonarrEvent::DeleteRootFolder(Some(root_folder_id))).into()) + .await?; + serde_json::to_string_pretty(&resp)? + } }; Ok(resp) diff --git a/src/cli/sonarr/delete_command_handler_tests.rs b/src/cli/sonarr/delete_command_handler_tests.rs index 378d2aa..9953225 100644 --- a/src/cli/sonarr/delete_command_handler_tests.rs +++ b/src/cli/sonarr/delete_command_handler_tests.rs @@ -123,6 +123,39 @@ mod tests { assert_eq!(delete_command, expected_args); } } + + #[test] + fn test_delete_root_folder_requires_arguments() { + let result = + Cli::command().try_get_matches_from(["managarr", "sonarr", "delete", "root-folder"]); + + assert!(result.is_err()); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_delete_root_folder_success() { + let expected_args = SonarrDeleteCommand::RootFolder { root_folder_id: 1 }; + + let result = Cli::try_parse_from([ + "managarr", + "sonarr", + "delete", + "root-folder", + "--root-folder-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 { @@ -224,5 +257,31 @@ mod tests { assert!(result.is_ok()); } + + #[tokio::test] + async fn test_handle_delete_root_folder_command() { + let expected_root_folder_id = 1; + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::( + SonarrEvent::DeleteRootFolder(Some(expected_root_folder_id)).into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Sonarr(SonarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::default())); + let delete_root_folder_command = SonarrDeleteCommand::RootFolder { root_folder_id: 1 }; + + let result = + SonarrDeleteCommandHandler::with(&app_arc, delete_root_folder_command, &mut mock_network) + .handle() + .await; + + assert!(result.is_ok()); + } } } From ce701c1ab7557fabd36c2ba059788f4634383b2d Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Fri, 22 Nov 2024 14:42:17 -0700 Subject: [PATCH 051/119] feat(network): Support for adding a root folder to Sonarr --- src/models/radarr_models.rs | 5 -- src/models/servarr_data/sonarr/sonarr_data.rs | 2 + .../servarr_data/sonarr/sonarr_data_tests.rs | 1 + src/models/servarr_models.rs | 5 ++ src/network/radarr_network.rs | 22 +++--- src/network/radarr_network_tests.rs | 4 +- src/network/sonarr_network.rs | 45 +++++++++++- src/network/sonarr_network_tests.rs | 72 ++++++++++++++++++- 8 files changed, 135 insertions(+), 21 deletions(-) diff --git a/src/models/radarr_models.rs b/src/models/radarr_models.rs index 283eeef..bb838b6 100644 --- a/src/models/radarr_models.rs +++ b/src/models/radarr_models.rs @@ -56,11 +56,6 @@ pub struct AddOptions { pub search_for_movie: bool, } -#[derive(Default, Serialize, Debug)] -pub struct AddRootFolderBody { - pub path: String, -} - #[derive(Default, Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] pub struct BlocklistResponse { pub records: Vec, diff --git a/src/models/servarr_data/sonarr/sonarr_data.rs b/src/models/servarr_data/sonarr/sonarr_data.rs index cd274d2..2c1b820 100644 --- a/src/models/servarr_data/sonarr/sonarr_data.rs +++ b/src/models/servarr_data/sonarr/sonarr_data.rs @@ -21,6 +21,7 @@ mod sonarr_data_tests; pub struct SonarrData { pub blocklist: StatefulTable, pub downloads: StatefulTable, + pub edit_root_folder: Option, pub history: StatefulTable, pub indexers: StatefulTable, pub indexer_settings: Option, @@ -41,6 +42,7 @@ impl Default for SonarrData { SonarrData { blocklist: StatefulTable::default(), downloads: StatefulTable::default(), + edit_root_folder: None, history: StatefulTable::default(), indexers: StatefulTable::default(), indexer_settings: None, diff --git a/src/models/servarr_data/sonarr/sonarr_data_tests.rs b/src/models/servarr_data/sonarr/sonarr_data_tests.rs index 60fecc2..9be5338 100644 --- a/src/models/servarr_data/sonarr/sonarr_data_tests.rs +++ b/src/models/servarr_data/sonarr/sonarr_data_tests.rs @@ -36,6 +36,7 @@ mod tests { assert!(sonarr_data.blocklist.is_empty()); assert!(sonarr_data.downloads.is_empty()); + assert!(sonarr_data.edit_root_folder.is_none()); assert!(sonarr_data.history.is_empty()); assert!(sonarr_data.indexers.is_empty()); assert!(sonarr_data.indexer_settings.is_none()); diff --git a/src/models/servarr_models.rs b/src/models/servarr_models.rs index 12c5234..b7d92a0 100644 --- a/src/models/servarr_models.rs +++ b/src/models/servarr_models.rs @@ -11,6 +11,11 @@ use super::HorizontallyScrollableText; #[path = "servarr_models_tests.rs"] mod servarr_models_tests; +#[derive(Default, Serialize, Debug)] +pub struct AddRootFolderBody { + pub path: String, +} + #[derive(Serialize, Deserialize, Default, PartialEq, Eq, Clone, Copy, Debug, ValueEnum)] #[serde(rename_all = "camelCase")] pub enum AuthenticationMethod { diff --git a/src/network/radarr_network.rs b/src/network/radarr_network.rs index 0b9e087..40a50b0 100644 --- a/src/network/radarr_network.rs +++ b/src/network/radarr_network.rs @@ -7,11 +7,11 @@ use serde_json::{json, Value}; use urlencoding::encode; use crate::models::radarr_models::{ - AddMovieBody, AddMovieSearchResult, AddOptions, AddRootFolderBody, BlocklistResponse, Collection, - CollectionMovie, CommandBody, Credit, CreditType, DeleteMovieParams, DiskSpace, DownloadRecord, - DownloadsResponse, EditCollectionParams, EditIndexerParams, EditMovieParams, IndexerSettings, - IndexerTestResult, Movie, MovieCommandBody, MovieHistoryItem, RadarrSerdeable, - ReleaseDownloadBody, SystemStatus, Tag, Task, TaskName, Update, + AddMovieBody, AddMovieSearchResult, AddOptions, BlocklistResponse, Collection, CollectionMovie, + CommandBody, Credit, CreditType, DeleteMovieParams, DiskSpace, DownloadRecord, DownloadsResponse, + EditCollectionParams, EditIndexerParams, EditMovieParams, IndexerSettings, IndexerTestResult, + Movie, MovieCommandBody, MovieHistoryItem, RadarrSerdeable, ReleaseDownloadBody, SystemStatus, + Tag, Task, TaskName, Update, }; use crate::models::servarr_data::radarr::modals::{ AddMovieModal, EditCollectionModal, EditIndexerModal, EditMovieModal, IndexerTestResultModalItem, @@ -19,7 +19,8 @@ use crate::models::servarr_data::radarr::modals::{ }; use crate::models::servarr_data::radarr::radarr_data::ActiveRadarrBlock; use crate::models::servarr_models::{ - HostConfig, Indexer, LogResponse, QualityProfile, QueueEvent, Release, RootFolder, SecurityConfig, + AddRootFolderBody, HostConfig, Indexer, LogResponse, QualityProfile, QueueEvent, Release, + RootFolder, SecurityConfig, }; use crate::models::stateful_table::StatefulTable; use crate::models::{HorizontallyScrollableText, Route, Scrollable, ScrollableText}; @@ -143,9 +144,10 @@ impl<'a, 'b> Network<'a, 'b> { ) -> Result { match radarr_event { RadarrEvent::AddMovie(body) => self.add_movie(body).await.map(RadarrSerdeable::from), - RadarrEvent::AddRootFolder(path) => { - self.add_root_folder(path).await.map(RadarrSerdeable::from) - } + RadarrEvent::AddRootFolder(path) => self + .add_radarr_root_folder(path) + .await + .map(RadarrSerdeable::from), RadarrEvent::AddTag(tag) => self.add_tag(tag).await.map(RadarrSerdeable::from), RadarrEvent::ClearBlocklist => self .clear_radarr_blocklist() @@ -371,7 +373,7 @@ impl<'a, 'b> Network<'a, 'b> { .await } - async fn add_root_folder(&mut self, root_folder: Option) -> Result { + async fn add_radarr_root_folder(&mut self, root_folder: Option) -> Result { info!("Adding new root folder to Radarr"); let event = RadarrEvent::AddRootFolder(None); let body = if let Some(path) = root_folder { diff --git a/src/network/radarr_network_tests.rs b/src/network/radarr_network_tests.rs index b1f6219..709fcd8 100644 --- a/src/network/radarr_network_tests.rs +++ b/src/network/radarr_network_tests.rs @@ -3633,7 +3633,7 @@ mod test { } #[tokio::test] - async fn test_handle_add_root_folder_event() { + async fn test_handle_add_radarr_root_folder_event() { let (async_server, app_arc, _server) = mock_servarr_api( RequestMethod::Post, Some(json!({ @@ -3666,7 +3666,7 @@ mod test { } #[tokio::test] - async fn test_handle_add_root_folder_event_uses_provided_path() { + async fn test_handle_add_radarr_root_folder_event_uses_provided_path() { let (async_server, app_arc, _server) = mock_servarr_api( RequestMethod::Post, Some(json!({ diff --git a/src/network/sonarr_network.rs b/src/network/sonarr_network.rs index b51406d..0f7bb6b 100644 --- a/src/network/sonarr_network.rs +++ b/src/network/sonarr_network.rs @@ -10,8 +10,8 @@ use crate::{ sonarr_data::ActiveSonarrBlock, }, servarr_models::{ - HostConfig, Indexer, LogResponse, QualityProfile, QueueEvent, Release, RootFolder, - SecurityConfig, + AddRootFolderBody, HostConfig, Indexer, LogResponse, QualityProfile, QueueEvent, Release, + RootFolder, SecurityConfig, }, sonarr_models::{ BlocklistResponse, DownloadRecord, DownloadsResponse, Episode, IndexerSettings, Series, @@ -31,6 +31,7 @@ mod sonarr_network_tests; #[derive(Debug, Eq, PartialEq, Clone)] pub enum SonarrEvent { + AddRootFolder(Option), ClearBlocklist, DeleteBlocklistItem(Option), DeleteDownload(Option), @@ -74,7 +75,9 @@ impl NetworkResource for SonarrEvent { SonarrEvent::GetLogs(_) => "/log", SonarrEvent::GetQualityProfiles => "/qualityprofile", SonarrEvent::GetQueuedEvents => "/command", - SonarrEvent::GetRootFolders | SonarrEvent::DeleteRootFolder(_) => "/rootfolder", + SonarrEvent::GetRootFolders + | SonarrEvent::DeleteRootFolder(_) + | SonarrEvent::AddRootFolder(_) => "/rootfolder", SonarrEvent::GetSeasonReleases(_) | SonarrEvent::GetEpisodeReleases(_) => "/release", SonarrEvent::GetSeriesHistory(_) => "/history/series", SonarrEvent::GetStatus => "/system/status", @@ -96,6 +99,10 @@ impl<'a, 'b> Network<'a, 'b> { sonarr_event: SonarrEvent, ) -> Result { match sonarr_event { + SonarrEvent::AddRootFolder(path) => self + .add_sonarr_root_folder(path) + .await + .map(SonarrSerdeable::from), SonarrEvent::ClearBlocklist => self .clear_sonarr_blocklist() .await @@ -188,6 +195,38 @@ impl<'a, 'b> Network<'a, 'b> { } } + async fn add_sonarr_root_folder(&mut self, root_folder: Option) -> Result { + info!("Adding new root folder to Sonarr"); + let event = SonarrEvent::AddRootFolder(None); + let body = if let Some(path) = root_folder { + AddRootFolderBody { path } + } else { + let mut app = self.app.lock().await; + let path = app + .data + .sonarr_data + .edit_root_folder + .as_ref() + .unwrap() + .text + .clone(); + + app.data.sonarr_data.edit_root_folder = None; + + AddRootFolderBody { path } + }; + + debug!("Add root folder body: {body:?}"); + + let request_props = self + .request_props_from(event, RequestMethod::Post, Some(body), None, None) + .await; + + self + .handle_request::(request_props, |_, _| ()) + .await + } + async fn clear_sonarr_blocklist(&mut self) -> Result<()> { info!("Clearing Sonarr blocklist"); let event = SonarrEvent::ClearBlocklist; diff --git a/src/network/sonarr_network_tests.rs b/src/network/sonarr_network_tests.rs index e2c026a..f1460f3 100644 --- a/src/network/sonarr_network_tests.rs +++ b/src/network/sonarr_network_tests.rs @@ -174,7 +174,12 @@ mod test { #[rstest] fn test_resource_root_folder( - #[values(SonarrEvent::GetRootFolders, SonarrEvent::DeleteRootFolder(None))] event: SonarrEvent, + #[values( + SonarrEvent::GetRootFolders, + SonarrEvent::DeleteRootFolder(None), + SonarrEvent::AddRootFolder(None) + )] + event: SonarrEvent, ) { assert_str_eq!(event.resource(), "/rootfolder"); } @@ -211,6 +216,71 @@ mod test { ); } + #[tokio::test] + async fn test_handle_add_sonarr_root_folder_event() { + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Post, + Some(json!({ + "path": "/nfs/test" + })), + Some(json!({})), + None, + SonarrEvent::AddRootFolder(None), + None, + None, + ) + .await; + + app_arc.lock().await.data.sonarr_data.edit_root_folder = Some("/nfs/test".into()); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + assert!(network + .handle_sonarr_event(SonarrEvent::AddRootFolder(None)) + .await + .is_ok()); + + async_server.assert_async().await; + assert!(app_arc + .lock() + .await + .data + .sonarr_data + .edit_root_folder + .is_none()); + } + + #[tokio::test] + async fn test_handle_add_sonarr_root_folder_event_uses_provided_path() { + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Post, + Some(json!({ + "path": "/test/test" + })), + Some(json!({})), + None, + SonarrEvent::AddRootFolder(None), + None, + None, + ) + .await; + + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + assert!(network + .handle_sonarr_event(SonarrEvent::AddRootFolder(Some("/test/test".to_owned()))) + .await + .is_ok()); + + async_server.assert_async().await; + assert!(app_arc + .lock() + .await + .data + .sonarr_data + .edit_root_folder + .is_none()); + } + #[tokio::test] async fn test_handle_clear_radarr_blocklist_event() { let blocklist_items = vec![ From 57eced64c049c7f145a14e8180397a3588b531c8 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Fri, 22 Nov 2024 14:58:14 -0700 Subject: [PATCH 052/119] feat(network): Support for adding tags to Sonarr --- src/models/radarr_models.rs | 9 +--- src/models/servarr_data/sonarr/sonarr_data.rs | 2 + .../servarr_data/sonarr/sonarr_data_tests.rs | 1 + src/models/servarr_models.rs | 7 +++ src/models/sonarr_models.rs | 6 ++- src/models/sonarr_models_tests.rs | 26 ++++++++++- src/network/radarr_network.rs | 10 ++--- src/network/radarr_network_tests.rs | 17 ++++++-- src/network/sonarr_network.rs | 26 ++++++++++- src/network/sonarr_network_tests.rs | 43 ++++++++++++++++++- 10 files changed, 127 insertions(+), 20 deletions(-) diff --git a/src/models/radarr_models.rs b/src/models/radarr_models.rs index bb838b6..9cf06cf 100644 --- a/src/models/radarr_models.rs +++ b/src/models/radarr_models.rs @@ -11,7 +11,7 @@ use crate::{models::HorizontallyScrollableText, serde_enum_from}; use super::servarr_models::{ HostConfig, Indexer, Language, LogResponse, QualityProfile, QualityWrapper, QueueEvent, Release, - RootFolder, SecurityConfig, + RootFolder, SecurityConfig, Tag, }; use super::{EnumDisplayStyle, Serdeable}; @@ -442,13 +442,6 @@ pub struct SystemStatus { pub start_time: DateTime, } -#[derive(Default, Clone, Serialize, Deserialize, Debug, PartialEq, Eq)] -pub struct Tag { - #[serde(deserialize_with = "super::from_i64")] - pub id: i64, - pub label: String, -} - #[derive(Default, Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct Task { diff --git a/src/models/servarr_data/sonarr/sonarr_data.rs b/src/models/servarr_data/sonarr/sonarr_data.rs index 2c1b820..211582c 100644 --- a/src/models/servarr_data/sonarr/sonarr_data.rs +++ b/src/models/servarr_data/sonarr/sonarr_data.rs @@ -34,6 +34,7 @@ pub struct SonarrData { pub series: StatefulTable, pub series_history: Option>, pub start_time: DateTime, + pub tags_map: BiMap, pub version: String, } @@ -55,6 +56,7 @@ impl Default for SonarrData { series: StatefulTable::default(), series_history: None, start_time: DateTime::default(), + tags_map: BiMap::default(), version: String::new(), } } diff --git a/src/models/servarr_data/sonarr/sonarr_data_tests.rs b/src/models/servarr_data/sonarr/sonarr_data_tests.rs index 9be5338..5c174be 100644 --- a/src/models/servarr_data/sonarr/sonarr_data_tests.rs +++ b/src/models/servarr_data/sonarr/sonarr_data_tests.rs @@ -49,6 +49,7 @@ mod tests { assert!(sonarr_data.series.is_empty()); assert!(sonarr_data.series_history.is_none()); assert_eq!(sonarr_data.start_time, >::default()); + assert!(sonarr_data.tags_map.is_empty()); assert!(sonarr_data.version.is_empty()); } } diff --git a/src/models/servarr_models.rs b/src/models/servarr_models.rs index b7d92a0..68f78ef 100644 --- a/src/models/servarr_models.rs +++ b/src/models/servarr_models.rs @@ -228,6 +228,13 @@ pub struct SecurityConfig { pub certificate_validation: CertificateValidation, } +#[derive(Default, Clone, Serialize, Deserialize, Debug, PartialEq, Eq)] +pub struct Tag { + #[serde(deserialize_with = "super::from_i64")] + pub id: i64, + pub label: String, +} + #[derive(Serialize, Deserialize, Default, Debug, Clone, Eq, PartialEq)] pub struct UnmappedFolder { pub name: String, diff --git a/src/models/sonarr_models.rs b/src/models/sonarr_models.rs index edba686..72d5f17 100644 --- a/src/models/sonarr_models.rs +++ b/src/models/sonarr_models.rs @@ -12,7 +12,7 @@ use crate::serde_enum_from; use super::{ servarr_models::{ HostConfig, Indexer, Language, LogResponse, QualityProfile, QualityWrapper, QueueEvent, - Release, RootFolder, SecurityConfig, + Release, RootFolder, SecurityConfig, Tag, }, EnumDisplayStyle, HorizontallyScrollableText, Serdeable, }; @@ -409,6 +409,8 @@ pub enum SonarrSerdeable { SonarrHistoryItems(Vec), SonarrHistoryWrapper(SonarrHistoryWrapper), SystemStatus(SystemStatus), + Tag(Tag), + Tags(Vec), BlocklistResponse(BlocklistResponse), LogResponse(LogResponse), } @@ -444,6 +446,8 @@ serde_enum_from!( SonarrHistoryItems(Vec), SonarrHistoryWrapper(SonarrHistoryWrapper), SystemStatus(SystemStatus), + Tag(Tag), + Tags(Vec), BlocklistResponse(BlocklistResponse), LogResponse(LogResponse), } diff --git a/src/models/sonarr_models_tests.rs b/src/models/sonarr_models_tests.rs index 8bee1a1..363ad4f 100644 --- a/src/models/sonarr_models_tests.rs +++ b/src/models/sonarr_models_tests.rs @@ -6,7 +6,7 @@ mod tests { use crate::models::{ servarr_models::{ HostConfig, Indexer, Log, LogResponse, QualityProfile, QueueEvent, Release, RootFolder, - SecurityConfig, + SecurityConfig, Tag, }, sonarr_models::{ BlocklistItem, BlocklistResponse, DownloadRecord, DownloadsResponse, Episode, @@ -370,4 +370,28 @@ mod tests { SonarrSerdeable::SecurityConfig(security_config) ); } + + #[test] + fn test_sonarr_serdeable_from_tag() { + let tag = Tag { + id: 1, + ..Tag::default() + }; + + let sonarr_serdeable: SonarrSerdeable = tag.clone().into(); + + assert_eq!(sonarr_serdeable, SonarrSerdeable::Tag(tag)); + } + + #[test] + fn test_sonarr_serdeable_from_tags() { + let tags = vec![Tag { + id: 1, + ..Tag::default() + }]; + + let sonarr_serdeable: SonarrSerdeable = tags.clone().into(); + + assert_eq!(sonarr_serdeable, SonarrSerdeable::Tags(tags)); + } } diff --git a/src/network/radarr_network.rs b/src/network/radarr_network.rs index 40a50b0..cef03a1 100644 --- a/src/network/radarr_network.rs +++ b/src/network/radarr_network.rs @@ -11,7 +11,7 @@ use crate::models::radarr_models::{ CommandBody, Credit, CreditType, DeleteMovieParams, DiskSpace, DownloadRecord, DownloadsResponse, EditCollectionParams, EditIndexerParams, EditMovieParams, IndexerSettings, IndexerTestResult, Movie, MovieCommandBody, MovieHistoryItem, RadarrSerdeable, ReleaseDownloadBody, SystemStatus, - Tag, Task, TaskName, Update, + Task, TaskName, Update, }; use crate::models::servarr_data::radarr::modals::{ AddMovieModal, EditCollectionModal, EditIndexerModal, EditMovieModal, IndexerTestResultModalItem, @@ -20,7 +20,7 @@ use crate::models::servarr_data::radarr::modals::{ use crate::models::servarr_data::radarr::radarr_data::ActiveRadarrBlock; use crate::models::servarr_models::{ AddRootFolderBody, HostConfig, Indexer, LogResponse, QualityProfile, QueueEvent, Release, - RootFolder, SecurityConfig, + RootFolder, SecurityConfig, Tag, }; use crate::models::stateful_table::StatefulTable; use crate::models::{HorizontallyScrollableText, Route, Scrollable, ScrollableText}; @@ -148,7 +148,7 @@ impl<'a, 'b> Network<'a, 'b> { .add_radarr_root_folder(path) .await .map(RadarrSerdeable::from), - RadarrEvent::AddTag(tag) => self.add_tag(tag).await.map(RadarrSerdeable::from), + RadarrEvent::AddTag(tag) => self.add_radarr_tag(tag).await.map(RadarrSerdeable::from), RadarrEvent::ClearBlocklist => self .clear_radarr_blocklist() .await @@ -405,7 +405,7 @@ impl<'a, 'b> Network<'a, 'b> { .await } - async fn add_tag(&mut self, tag: String) -> Result { + async fn add_radarr_tag(&mut self, tag: String) -> Result { info!("Adding a new Radarr tag"); let event = RadarrEvent::AddTag(String::new()); @@ -2234,7 +2234,7 @@ impl<'a, 'b> Network<'a, 'b> { for tag in missing_tags_vec { self - .add_tag(tag.trim().to_owned()) + .add_radarr_tag(tag.trim().to_owned()) .await .expect("Unable to add tag"); } diff --git a/src/network/radarr_network_tests.rs b/src/network/radarr_network_tests.rs index 709fcd8..ddfa1a3 100644 --- a/src/network/radarr_network_tests.rs +++ b/src/network/radarr_network_tests.rs @@ -164,6 +164,18 @@ mod test { assert_str_eq!(event.resource(), "/rootfolder"); } + #[rstest] + fn test_resource_tag( + #[values( + RadarrEvent::AddTag(String::new()), + RadarrEvent::GetTags, + RadarrEvent::DeleteTag(0) + )] + event: RadarrEvent, + ) { + assert_str_eq!(event.resource(), "/tag"); + } + #[rstest] fn test_resource_release( #[values(RadarrEvent::GetReleases(None), RadarrEvent::DownloadRelease(None))] @@ -213,7 +225,6 @@ mod test { #[case(RadarrEvent::GetOverview, "/diskspace")] #[case(RadarrEvent::GetQualityProfiles, "/qualityprofile")] #[case(RadarrEvent::GetStatus, "/system/status")] - #[case(RadarrEvent::GetTags, "/tag")] #[case(RadarrEvent::GetTasks, "/system/task")] #[case(RadarrEvent::GetUpdates, "/update")] #[case(RadarrEvent::TestIndexer(None), "/indexer/test")] @@ -2814,7 +2825,7 @@ mod test { } #[tokio::test] - async fn test_handle_add_tag() { + async fn test_handle_add_radarr_tag() { let tag_json = json!({ "id": 3, "label": "testing" }); let response: Tag = serde_json::from_value(tag_json.clone()).unwrap(); let (async_server, app_arc, _server) = mock_servarr_api( @@ -2822,7 +2833,7 @@ mod test { Some(json!({ "label": "testing" })), Some(tag_json), None, - RadarrEvent::GetTags, + RadarrEvent::AddTag(String::new()), None, None, ) diff --git a/src/network/sonarr_network.rs b/src/network/sonarr_network.rs index 0f7bb6b..31bebdc 100644 --- a/src/network/sonarr_network.rs +++ b/src/network/sonarr_network.rs @@ -11,7 +11,7 @@ use crate::{ }, servarr_models::{ AddRootFolderBody, HostConfig, Indexer, LogResponse, QualityProfile, QueueEvent, Release, - RootFolder, SecurityConfig, + RootFolder, SecurityConfig, Tag, }, sonarr_models::{ BlocklistResponse, DownloadRecord, DownloadsResponse, Episode, IndexerSettings, Series, @@ -32,6 +32,7 @@ mod sonarr_network_tests; #[derive(Debug, Eq, PartialEq, Clone)] pub enum SonarrEvent { AddRootFolder(Option), + AddTag(String), ClearBlocklist, DeleteBlocklistItem(Option), DeleteDownload(Option), @@ -63,6 +64,7 @@ pub enum SonarrEvent { impl NetworkResource for SonarrEvent { fn resource(&self) -> &'static str { match &self { + SonarrEvent::AddTag(_) => "/tag", SonarrEvent::ClearBlocklist => "/blocklist/bulk", SonarrEvent::DeleteBlocklistItem(_) => "/blocklist", SonarrEvent::GetAllIndexerSettings => "/config/indexer", @@ -103,6 +105,7 @@ impl<'a, 'b> Network<'a, 'b> { .add_sonarr_root_folder(path) .await .map(SonarrSerdeable::from), + SonarrEvent::AddTag(tag) => self.add_sonarr_tag(tag).await.map(SonarrSerdeable::from), SonarrEvent::ClearBlocklist => self .clear_sonarr_blocklist() .await @@ -227,6 +230,27 @@ impl<'a, 'b> Network<'a, 'b> { .await } + async fn add_sonarr_tag(&mut self, tag: String) -> Result { + info!("Adding a new Sonarr tag"); + let event = SonarrEvent::AddTag(String::new()); + + let request_props = self + .request_props_from( + event, + RequestMethod::Post, + Some(json!({ "label": tag })), + None, + None, + ) + .await; + + self + .handle_request::(request_props, |tag, mut app| { + app.data.sonarr_data.tags_map.insert(tag.id, tag.label); + }) + .await + } + async fn clear_sonarr_blocklist(&mut self) -> Result<()> { info!("Clearing Sonarr blocklist"); let event = SonarrEvent::ClearBlocklist; diff --git a/src/network/sonarr_network_tests.rs b/src/network/sonarr_network_tests.rs index f1460f3..dc432f0 100644 --- a/src/network/sonarr_network_tests.rs +++ b/src/network/sonarr_network_tests.rs @@ -18,7 +18,7 @@ mod test { use crate::models::servarr_data::sonarr::sonarr_data::ActiveSonarrBlock; use crate::models::servarr_models::{ HostConfig, Indexer, IndexerField, Language, LogResponse, Quality, QualityProfile, - QualityWrapper, QueueEvent, Release, RootFolder, SecurityConfig, + QualityWrapper, QueueEvent, Release, RootFolder, SecurityConfig, Tag, }; use crate::models::sonarr_models::SystemStatus; use crate::models::sonarr_models::{ @@ -138,6 +138,11 @@ mod test { assert_str_eq!(event.resource(), "/series"); } + #[rstest] + fn test_resource_tag(#[values(SonarrEvent::AddTag(String::new()))] event: SonarrEvent) { + assert_str_eq!(event.resource(), "/tag"); + } + #[rstest] fn test_resource_host_config( #[values(SonarrEvent::GetHostConfig, SonarrEvent::GetSecurityConfig)] event: SonarrEvent, @@ -281,6 +286,42 @@ mod test { .is_none()); } + #[tokio::test] + async fn test_handle_add_sonarr_tag() { + let tag_json = json!({ "id": 3, "label": "testing" }); + let response: Tag = serde_json::from_value(tag_json.clone()).unwrap(); + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Post, + Some(json!({ "label": "testing" })), + Some(tag_json), + None, + SonarrEvent::AddTag(String::new()), + None, + None, + ) + .await; + app_arc.lock().await.data.sonarr_data.tags_map = + BiMap::from_iter([(1, "usenet".to_owned()), (2, "test".to_owned())]); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + if let SonarrSerdeable::Tag(tag) = network + .handle_sonarr_event(SonarrEvent::AddTag("testing".to_owned())) + .await + .unwrap() + { + async_server.assert_async().await; + assert_eq!( + app_arc.lock().await.data.sonarr_data.tags_map, + BiMap::from_iter([ + (1, "usenet".to_owned()), + (2, "test".to_owned()), + (3, "testing".to_owned()) + ]) + ); + assert_eq!(tag, response); + } + } + #[tokio::test] async fn test_handle_clear_radarr_blocklist_event() { let blocklist_items = vec![ From 208acafc73c8dce99aea57fd8b462391d6b46b2d Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Fri, 22 Nov 2024 15:02:30 -0700 Subject: [PATCH 053/119] feat(network): Support for deleting tags from Sonarr --- src/network/radarr_network.rs | 7 +++++-- src/network/radarr_network_tests.rs | 2 +- src/network/sonarr_network.rs | 26 +++++++++++++++++++++++++- src/network/sonarr_network_tests.rs | 26 +++++++++++++++++++++++++- 4 files changed, 56 insertions(+), 5 deletions(-) diff --git a/src/network/radarr_network.rs b/src/network/radarr_network.rs index cef03a1..faecf90 100644 --- a/src/network/radarr_network.rs +++ b/src/network/radarr_network.rs @@ -172,7 +172,10 @@ impl<'a, 'b> Network<'a, 'b> { .delete_radarr_root_folder(root_folder_id) .await .map(RadarrSerdeable::from), - RadarrEvent::DeleteTag(tag_id) => self.delete_tag(tag_id).await.map(RadarrSerdeable::from), + RadarrEvent::DeleteTag(tag_id) => self + .delete_radarr_tag(tag_id) + .await + .map(RadarrSerdeable::from), RadarrEvent::DownloadRelease(params) => self .download_release(params) .await @@ -426,7 +429,7 @@ impl<'a, 'b> Network<'a, 'b> { .await } - async fn delete_tag(&mut self, id: i64) -> Result<()> { + async fn delete_radarr_tag(&mut self, id: i64) -> Result<()> { info!("Deleting Radarr tag with id: {id}"); let event = RadarrEvent::DeleteTag(id); diff --git a/src/network/radarr_network_tests.rs b/src/network/radarr_network_tests.rs index ddfa1a3..c5bc4b3 100644 --- a/src/network/radarr_network_tests.rs +++ b/src/network/radarr_network_tests.rs @@ -2861,7 +2861,7 @@ mod test { } #[tokio::test] - async fn test_handle_delete_tag_event() { + async fn test_handle_delete_radarr_tag_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 31bebdc..3075215 100644 --- a/src/network/sonarr_network.rs +++ b/src/network/sonarr_network.rs @@ -38,6 +38,7 @@ pub enum SonarrEvent { DeleteDownload(Option), DeleteIndexer(Option), DeleteRootFolder(Option), + DeleteTag(i64), GetAllIndexerSettings, GetBlocklist, GetDownloads, @@ -64,7 +65,7 @@ pub enum SonarrEvent { impl NetworkResource for SonarrEvent { fn resource(&self) -> &'static str { match &self { - SonarrEvent::AddTag(_) => "/tag", + SonarrEvent::AddTag(_) | SonarrEvent::DeleteTag(_) => "/tag", SonarrEvent::ClearBlocklist => "/blocklist/bulk", SonarrEvent::DeleteBlocklistItem(_) => "/blocklist", SonarrEvent::GetAllIndexerSettings => "/config/indexer", @@ -130,6 +131,10 @@ impl<'a, 'b> Network<'a, 'b> { .delete_sonarr_root_folder(root_folder_id) .await .map(SonarrSerdeable::from), + SonarrEvent::DeleteTag(tag_id) => self + .delete_sonarr_tag(tag_id) + .await + .map(SonarrSerdeable::from), SonarrEvent::GetBlocklist => self.get_sonarr_blocklist().await.map(SonarrSerdeable::from), SonarrEvent::GetDownloads => self.get_sonarr_downloads().await.map(SonarrSerdeable::from), SonarrEvent::GetEpisodes(series_id) => self @@ -414,6 +419,25 @@ impl<'a, 'b> Network<'a, 'b> { .await } + async fn delete_sonarr_tag(&mut self, id: i64) -> Result<()> { + info!("Deleting Sonarr tag with id: {id}"); + let event = SonarrEvent::DeleteTag(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_all_sonarr_indexer_settings(&mut self) -> Result { info!("Fetching Sonarr indexer settings"); let event = SonarrEvent::GetAllIndexerSettings; diff --git a/src/network/sonarr_network_tests.rs b/src/network/sonarr_network_tests.rs index dc432f0..5de32ac 100644 --- a/src/network/sonarr_network_tests.rs +++ b/src/network/sonarr_network_tests.rs @@ -139,7 +139,9 @@ mod test { } #[rstest] - fn test_resource_tag(#[values(SonarrEvent::AddTag(String::new()))] event: SonarrEvent) { + fn test_resource_tag( + #[values(SonarrEvent::AddTag(String::new()), SonarrEvent::DeleteTag(0))] event: SonarrEvent, + ) { assert_str_eq!(event.resource(), "/tag"); } @@ -548,6 +550,28 @@ mod test { async_server.assert_async().await; } + #[tokio::test] + async fn test_handle_delete_sonarr_tag_event() { + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Delete, + None, + None, + None, + SonarrEvent::DeleteTag(1), + Some("/1"), + None, + ) + .await; + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + assert!(network + .handle_sonarr_event(SonarrEvent::DeleteTag(1)) + .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) { From c5328917dea140a26dd6b113d60acd2a7e9f7973 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Fri, 22 Nov 2024 15:05:56 -0700 Subject: [PATCH 054/119] feat(network): Support for fetching and listing all Sonarr tags --- src/network/radarr_network.rs | 4 +-- src/network/radarr_network_tests.rs | 2 +- src/network/sonarr_network.rs | 22 +++++++++++++++- src/network/sonarr_network_tests.rs | 40 ++++++++++++++++++++++++++++- 4 files changed, 63 insertions(+), 5 deletions(-) diff --git a/src/network/radarr_network.rs b/src/network/radarr_network.rs index faecf90..717fe32 100644 --- a/src/network/radarr_network.rs +++ b/src/network/radarr_network.rs @@ -242,7 +242,7 @@ impl<'a, 'b> Network<'a, 'b> { .await .map(RadarrSerdeable::from), RadarrEvent::GetStatus => self.get_radarr_status().await.map(RadarrSerdeable::from), - RadarrEvent::GetTags => self.get_tags().await.map(RadarrSerdeable::from), + RadarrEvent::GetTags => self.get_radarr_tags().await.map(RadarrSerdeable::from), RadarrEvent::GetTasks => self.get_tasks().await.map(RadarrSerdeable::from), RadarrEvent::GetUpdates => self.get_updates().await.map(RadarrSerdeable::from), RadarrEvent::HealthCheck => self @@ -1837,7 +1837,7 @@ impl<'a, 'b> Network<'a, 'b> { .await } - async fn get_tags(&mut self) -> Result> { + async fn get_radarr_tags(&mut self) -> Result> { info!("Fetching Radarr tags"); let event = RadarrEvent::GetTags; diff --git a/src/network/radarr_network_tests.rs b/src/network/radarr_network_tests.rs index c5bc4b3..bfadf03 100644 --- a/src/network/radarr_network_tests.rs +++ b/src/network/radarr_network_tests.rs @@ -2633,7 +2633,7 @@ mod test { } #[tokio::test] - async fn test_handle_get_tags_event() { + async fn test_handle_get_radarr_tags_event() { let tags_json = json!([{ "id": 2222, "label": "usenet" diff --git a/src/network/sonarr_network.rs b/src/network/sonarr_network.rs index 3075215..22341b5 100644 --- a/src/network/sonarr_network.rs +++ b/src/network/sonarr_network.rs @@ -58,6 +58,7 @@ pub enum SonarrEvent { GetSeriesDetails(Option), GetSeriesHistory(Option), GetStatus, + GetTags, HealthCheck, ListSeries, } @@ -65,7 +66,7 @@ pub enum SonarrEvent { impl NetworkResource for SonarrEvent { fn resource(&self) -> &'static str { match &self { - SonarrEvent::AddTag(_) | SonarrEvent::DeleteTag(_) => "/tag", + SonarrEvent::AddTag(_) | SonarrEvent::DeleteTag(_) | SonarrEvent::GetTags => "/tag", SonarrEvent::ClearBlocklist => "/blocklist/bulk", SonarrEvent::DeleteBlocklistItem(_) => "/blocklist", SonarrEvent::GetAllIndexerSettings => "/config/indexer", @@ -195,6 +196,7 @@ impl<'a, 'b> Network<'a, 'b> { .await .map(SonarrSerdeable::from), SonarrEvent::GetStatus => self.get_sonarr_status().await.map(SonarrSerdeable::from), + SonarrEvent::GetTags => self.get_sonarr_tags().await.map(SonarrSerdeable::from), SonarrEvent::HealthCheck => self .get_sonarr_healthcheck() .await @@ -1112,6 +1114,24 @@ impl<'a, 'b> Network<'a, 'b> { .await } + async fn get_sonarr_tags(&mut self) -> Result> { + info!("Fetching Sonarr tags"); + let event = SonarrEvent::GetTags; + + let request_props = self + .request_props_from(event, RequestMethod::Get, None::<()>, None, None) + .await; + + self + .handle_request::<(), Vec>(request_props, |tags_vec, mut app| { + app.data.sonarr_data.tags_map = tags_vec + .into_iter() + .map(|tag| (tag.id, tag.label)) + .collect(); + }) + .await + } + async fn extract_series_id(&mut self, series_id: Option) -> (i64, String) { let series_id = if let Some(id) = series_id { id diff --git a/src/network/sonarr_network_tests.rs b/src/network/sonarr_network_tests.rs index 5de32ac..1401935 100644 --- a/src/network/sonarr_network_tests.rs +++ b/src/network/sonarr_network_tests.rs @@ -140,7 +140,12 @@ mod test { #[rstest] fn test_resource_tag( - #[values(SonarrEvent::AddTag(String::new()), SonarrEvent::DeleteTag(0))] event: SonarrEvent, + #[values( + SonarrEvent::AddTag(String::new()), + SonarrEvent::DeleteTag(0), + SonarrEvent::GetTags + )] + event: SonarrEvent, ) { assert_str_eq!(event.resource(), "/tag"); } @@ -3588,6 +3593,39 @@ mod test { } } + #[tokio::test] + async fn test_handle_get_sonarr_tags_event() { + let tags_json = json!([{ + "id": 2222, + "label": "usenet" + }]); + let response: Vec = serde_json::from_value(tags_json.clone()).unwrap(); + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(tags_json), + None, + SonarrEvent::GetTags, + None, + None, + ) + .await; + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + if let SonarrSerdeable::Tags(tags) = network + .handle_sonarr_event(SonarrEvent::GetTags) + .await + .unwrap() + { + async_server.assert_async().await; + assert_eq!( + app_arc.lock().await.data.sonarr_data.tags_map, + BiMap::from_iter([(2222i64, "usenet".to_owned())]) + ); + assert_eq!(tags, response); + } + } + #[tokio::test] async fn test_extract_series_id() { let app_arc = Arc::new(Mutex::new(App::default())); From 1cc95e2cd1a360a1ea8b7a55214a8b18fa777d3f Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Fri, 22 Nov 2024 15:22:45 -0700 Subject: [PATCH 055/119] feat(cli): CLI support for adding a tag to Sonarr --- src/cli/radarr/add_command_handler.rs | 6 +- src/cli/sonarr/add_command_handler.rs | 65 ++++++++++++++ src/cli/sonarr/add_command_handler_tests.rs | 98 +++++++++++++++++++++ src/cli/sonarr/mod.rs | 12 +++ src/cli/sonarr/sonarr_command_tests.rs | 32 ++++++- 5 files changed, 208 insertions(+), 5 deletions(-) create mode 100644 src/cli/sonarr/add_command_handler.rs create mode 100644 src/cli/sonarr/add_command_handler_tests.rs diff --git a/src/cli/radarr/add_command_handler.rs b/src/cli/radarr/add_command_handler.rs index 007306f..70efb70 100644 --- a/src/cli/radarr/add_command_handler.rs +++ b/src/cli/radarr/add_command_handler.rs @@ -132,21 +132,21 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, RadarrAddCommand> for RadarrAddCommandHan }; let resp = self .network - .handle_network_event((RadarrEvent::AddMovie(Some(body))).into()) + .handle_network_event(RadarrEvent::AddMovie(Some(body)).into()) .await?; serde_json::to_string_pretty(&resp)? } RadarrAddCommand::RootFolder { root_folder_path } => { let resp = self .network - .handle_network_event((RadarrEvent::AddRootFolder(Some(root_folder_path.clone()))).into()) + .handle_network_event(RadarrEvent::AddRootFolder(Some(root_folder_path)).into()) .await?; serde_json::to_string_pretty(&resp)? } RadarrAddCommand::Tag { name } => { let resp = self .network - .handle_network_event((RadarrEvent::AddTag(name.clone())).into()) + .handle_network_event(RadarrEvent::AddTag(name).into()) .await?; serde_json::to_string_pretty(&resp)? } diff --git a/src/cli/sonarr/add_command_handler.rs b/src/cli/sonarr/add_command_handler.rs new file mode 100644 index 0000000..2b0d44c --- /dev/null +++ b/src/cli/sonarr/add_command_handler.rs @@ -0,0 +1,65 @@ +use std::sync::Arc; + +use anyhow::Result; +use clap::Subcommand; +use tokio::sync::Mutex; + +use crate::{ + app::App, + cli::{CliCommandHandler, Command}, + network::{sonarr_network::SonarrEvent, NetworkTrait}, +}; + +use super::SonarrCommand; + +#[cfg(test)] +#[path = "add_command_handler_tests.rs"] +mod add_command_handler_tests; + +#[derive(Debug, Clone, PartialEq, Eq, Subcommand)] +pub enum SonarrAddCommand { + #[command(about = "Add new tag")] + Tag { + #[arg(long, help = "The name of the tag to be added", required = true)] + name: String, + }, +} + +impl From for Command { + fn from(value: SonarrAddCommand) -> Self { + Command::Sonarr(SonarrCommand::Add(value)) + } +} + +pub(super) struct SonarrAddCommandHandler<'a, 'b> { + _app: &'a Arc>>, + command: SonarrAddCommand, + network: &'a mut dyn NetworkTrait, +} + +impl<'a, 'b> CliCommandHandler<'a, 'b, SonarrAddCommand> for SonarrAddCommandHandler<'a, 'b> { + fn with( + _app: &'a Arc>>, + command: SonarrAddCommand, + network: &'a mut dyn NetworkTrait, + ) -> Self { + SonarrAddCommandHandler { + _app, + command, + network, + } + } + + async fn handle(self) -> Result { + let result = match self.command { + SonarrAddCommand::Tag { name } => { + let resp = self + .network + .handle_network_event(SonarrEvent::AddTag(name).into()) + .await?; + serde_json::to_string_pretty(&resp)? + } + }; + Ok(result) + } +} diff --git a/src/cli/sonarr/add_command_handler_tests.rs b/src/cli/sonarr/add_command_handler_tests.rs new file mode 100644 index 0000000..a50781e --- /dev/null +++ b/src/cli/sonarr/add_command_handler_tests.rs @@ -0,0 +1,98 @@ +#[cfg(test)] +mod tests { + use clap::{error::ErrorKind, CommandFactory, Parser}; + + use crate::{ + cli::{ + sonarr::{add_command_handler::SonarrAddCommand, SonarrCommand}, + Command, + }, + Cli, + }; + + #[test] + fn test_sonarr_add_command_from() { + let command = SonarrAddCommand::Tag { + name: String::new(), + }; + + let result = Command::from(command.clone()); + + assert_eq!(result, Command::Sonarr(SonarrCommand::Add(command))); + } + + mod cli { + use super::*; + use pretty_assertions::assert_eq; + + #[test] + fn test_add_tag_requires_arguments() { + let result = Cli::command().try_get_matches_from(["managarr", "sonarr", "add", "tag"]); + + assert!(result.is_err()); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_add_tag_success() { + let expected_args = SonarrAddCommand::Tag { + name: "test".to_owned(), + }; + + let result = Cli::try_parse_from(["managarr", "sonarr", "add", "tag", "--name", "test"]); + + assert!(result.is_ok()); + + if let Some(Command::Sonarr(SonarrCommand::Add(add_command))) = result.unwrap().command { + assert_eq!(add_command, expected_args); + } + } + } + + mod handler { + use std::sync::Arc; + + use crate::{ + app::App, + cli::{sonarr::add_command_handler::SonarrAddCommandHandler, CliCommandHandler}, + models::{sonarr_models::SonarrSerdeable, Serdeable}, + network::{sonarr_network::SonarrEvent, MockNetworkTrait, NetworkEvent}, + }; + + use super::*; + use mockall::predicate::eq; + + use serde_json::json; + use tokio::sync::Mutex; + + #[tokio::test] + async fn test_handle_add_tag_command() { + let expected_tag_name = "test".to_owned(); + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::( + SonarrEvent::AddTag(expected_tag_name.clone()).into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Sonarr(SonarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::default())); + let add_tag_command = SonarrAddCommand::Tag { + name: expected_tag_name, + }; + + let result = SonarrAddCommandHandler::with(&app_arc, add_tag_command, &mut mock_network) + .handle() + .await; + + assert!(result.is_ok()); + } + } +} diff --git a/src/cli/sonarr/mod.rs b/src/cli/sonarr/mod.rs index 52239a7..73fd53d 100644 --- a/src/cli/sonarr/mod.rs +++ b/src/cli/sonarr/mod.rs @@ -1,5 +1,6 @@ use std::sync::Arc; +use add_command_handler::{SonarrAddCommand, SonarrAddCommandHandler}; use anyhow::Result; use clap::Subcommand; use delete_command_handler::{SonarrDeleteCommand, SonarrDeleteCommandHandler}; @@ -14,6 +15,7 @@ use crate::{ use super::{CliCommandHandler, Command}; +mod add_command_handler; mod delete_command_handler; mod get_command_handler; mod list_command_handler; @@ -24,6 +26,11 @@ mod sonarr_command_tests; #[derive(Debug, Clone, PartialEq, Eq, Subcommand)] pub enum SonarrCommand { + #[command( + subcommand, + about = "Commands to add or create new resources within your Sonarr instance" + )] + Add(SonarrAddCommand), #[command( subcommand, about = "Commands to delete resources from your Sonarr instance" @@ -92,6 +99,11 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, SonarrCommand> for SonarrCliHandler<'a, ' async fn handle(self) -> Result { let result = match self.command { + SonarrCommand::Add(add_command) => { + SonarrAddCommandHandler::with(self.app, add_command, self.network) + .handle() + .await? + } SonarrCommand::Delete(delete_command) => { SonarrDeleteCommandHandler::with(self.app, delete_command, self.network) .handle() diff --git a/src/cli/sonarr/sonarr_command_tests.rs b/src/cli/sonarr/sonarr_command_tests.rs index cf22b4b..0d72ec9 100644 --- a/src/cli/sonarr/sonarr_command_tests.rs +++ b/src/cli/sonarr/sonarr_command_tests.rs @@ -114,8 +114,9 @@ mod tests { app::App, cli::{ sonarr::{ - delete_command_handler::SonarrDeleteCommand, get_command_handler::SonarrGetCommand, - list_command_handler::SonarrListCommand, SonarrCliHandler, SonarrCommand, + add_command_handler::SonarrAddCommand, delete_command_handler::SonarrDeleteCommand, + get_command_handler::SonarrGetCommand, list_command_handler::SonarrListCommand, + SonarrCliHandler, SonarrCommand, }, CliCommandHandler, }, @@ -215,6 +216,33 @@ mod tests { assert!(result.is_ok()); } + #[tokio::test] + async fn test_sonarr_cli_handler_delegates_add_commands_to_the_add_command_handler() { + let expected_tag_name = "test".to_owned(); + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::( + SonarrEvent::AddTag(expected_tag_name.clone()).into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Sonarr(SonarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::default())); + let add_tag_command = SonarrCommand::Add(SonarrAddCommand::Tag { + name: expected_tag_name, + }); + + let result = SonarrCliHandler::with(&app_arc, add_tag_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; From eefe6392df29ccb6e1d217972d4fcfbe119b35e9 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Fri, 22 Nov 2024 15:25:33 -0700 Subject: [PATCH 056/119] feat(cli): Support for adding a root folder to Sonarr --- src/cli/sonarr/add_command_handler.rs | 12 ++++ src/cli/sonarr/add_command_handler_tests.rs | 62 +++++++++++++++++++++ 2 files changed, 74 insertions(+) diff --git a/src/cli/sonarr/add_command_handler.rs b/src/cli/sonarr/add_command_handler.rs index 2b0d44c..3cb3978 100644 --- a/src/cli/sonarr/add_command_handler.rs +++ b/src/cli/sonarr/add_command_handler.rs @@ -18,6 +18,11 @@ mod add_command_handler_tests; #[derive(Debug, Clone, PartialEq, Eq, Subcommand)] pub enum SonarrAddCommand { + #[command(about = "Add a new root folder")] + RootFolder { + #[arg(long, help = "The path of the new root folder", required = true)] + root_folder_path: String, + }, #[command(about = "Add new tag")] Tag { #[arg(long, help = "The name of the tag to be added", required = true)] @@ -52,6 +57,13 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, SonarrAddCommand> for SonarrAddCommandHan async fn handle(self) -> Result { let result = match self.command { + SonarrAddCommand::RootFolder { root_folder_path } => { + let resp = self + .network + .handle_network_event(SonarrEvent::AddRootFolder(Some(root_folder_path)).into()) + .await?; + serde_json::to_string_pretty(&resp)? + } SonarrAddCommand::Tag { name } => { let resp = self .network diff --git a/src/cli/sonarr/add_command_handler_tests.rs b/src/cli/sonarr/add_command_handler_tests.rs index a50781e..25c244c 100644 --- a/src/cli/sonarr/add_command_handler_tests.rs +++ b/src/cli/sonarr/add_command_handler_tests.rs @@ -25,6 +25,40 @@ mod tests { use super::*; use pretty_assertions::assert_eq; + #[test] + fn test_add_root_folder_requires_arguments() { + let result = + Cli::command().try_get_matches_from(["managarr", "sonarr", "add", "root-folder"]); + + assert!(result.is_err()); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_add_root_folder_success() { + let expected_args = SonarrAddCommand::RootFolder { + root_folder_path: "/nfs/test".to_owned(), + }; + + let result = Cli::try_parse_from([ + "managarr", + "sonarr", + "add", + "root-folder", + "--root-folder-path", + "/nfs/test", + ]); + + assert!(result.is_ok()); + + if let Some(Command::Sonarr(SonarrCommand::Add(add_command))) = result.unwrap().command { + assert_eq!(add_command, expected_args); + } + } + #[test] fn test_add_tag_requires_arguments() { let result = Cli::command().try_get_matches_from(["managarr", "sonarr", "add", "tag"]); @@ -68,6 +102,34 @@ mod tests { use serde_json::json; use tokio::sync::Mutex; + #[tokio::test] + async fn test_handle_add_root_folder_command() { + let expected_root_folder_path = "/nfs/test".to_owned(); + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::( + SonarrEvent::AddRootFolder(Some(expected_root_folder_path.clone())).into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Sonarr(SonarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::default())); + let add_root_folder_command = SonarrAddCommand::RootFolder { + root_folder_path: expected_root_folder_path, + }; + + let result = + SonarrAddCommandHandler::with(&app_arc, add_root_folder_command, &mut mock_network) + .handle() + .await; + + assert!(result.is_ok()); + } + #[tokio::test] async fn test_handle_add_tag_command() { let expected_tag_name = "test".to_owned(); From d96316577a6d23152f20d1133666132e1a00ba9c Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Fri, 22 Nov 2024 15:35:30 -0700 Subject: [PATCH 057/119] feat(cli): Support for listing all Sonarr tags --- src/cli/radarr/delete_command_handler.rs | 12 +++---- src/cli/radarr/edit_command_handler.rs | 8 ++--- src/cli/radarr/get_command_handler.rs | 12 +++---- src/cli/radarr/list_command_handler.rs | 24 +++++++------- src/cli/radarr/mod.rs | 16 +++++----- src/cli/radarr/refresh_command_handler.rs | 8 ++--- src/cli/sonarr/delete_command_handler.rs | 8 ++--- src/cli/sonarr/get_command_handler.rs | 12 +++---- src/cli/sonarr/list_command_handler.rs | 33 +++++++++++++------- src/cli/sonarr/list_command_handler_tests.rs | 4 ++- 10 files changed, 74 insertions(+), 63 deletions(-) diff --git a/src/cli/radarr/delete_command_handler.rs b/src/cli/radarr/delete_command_handler.rs index 8568c88..2f10c57 100644 --- a/src/cli/radarr/delete_command_handler.rs +++ b/src/cli/radarr/delete_command_handler.rs @@ -89,21 +89,21 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, RadarrDeleteCommand> for RadarrDeleteComm RadarrDeleteCommand::BlocklistItem { blocklist_item_id } => { let resp = self .network - .handle_network_event((RadarrEvent::DeleteBlocklistItem(Some(blocklist_item_id))).into()) + .handle_network_event(RadarrEvent::DeleteBlocklistItem(Some(blocklist_item_id)).into()) .await?; serde_json::to_string_pretty(&resp)? } RadarrDeleteCommand::Download { download_id } => { let resp = self .network - .handle_network_event((RadarrEvent::DeleteDownload(Some(download_id))).into()) + .handle_network_event(RadarrEvent::DeleteDownload(Some(download_id)).into()) .await?; serde_json::to_string_pretty(&resp)? } RadarrDeleteCommand::Indexer { indexer_id } => { let resp = self .network - .handle_network_event((RadarrEvent::DeleteIndexer(Some(indexer_id))).into()) + .handle_network_event(RadarrEvent::DeleteIndexer(Some(indexer_id)).into()) .await?; serde_json::to_string_pretty(&resp)? } @@ -119,21 +119,21 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, RadarrDeleteCommand> for RadarrDeleteComm }; let resp = self .network - .handle_network_event((RadarrEvent::DeleteMovie(Some(delete_movie_params))).into()) + .handle_network_event(RadarrEvent::DeleteMovie(Some(delete_movie_params)).into()) .await?; serde_json::to_string_pretty(&resp)? } RadarrDeleteCommand::RootFolder { root_folder_id } => { let resp = self .network - .handle_network_event((RadarrEvent::DeleteRootFolder(Some(root_folder_id))).into()) + .handle_network_event(RadarrEvent::DeleteRootFolder(Some(root_folder_id)).into()) .await?; serde_json::to_string_pretty(&resp)? } RadarrDeleteCommand::Tag { tag_id } => { let resp = self .network - .handle_network_event((RadarrEvent::DeleteTag(tag_id)).into()) + .handle_network_event(RadarrEvent::DeleteTag(tag_id).into()) .await?; serde_json::to_string_pretty(&resp)? } diff --git a/src/cli/radarr/edit_command_handler.rs b/src/cli/radarr/edit_command_handler.rs index f3396c9..ca731d3 100644 --- a/src/cli/radarr/edit_command_handler.rs +++ b/src/cli/radarr/edit_command_handler.rs @@ -390,7 +390,7 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, RadarrEditCommand> for RadarrEditCommandH }; self .network - .handle_network_event((RadarrEvent::EditAllIndexerSettings(Some(params))).into()) + .handle_network_event(RadarrEvent::EditAllIndexerSettings(Some(params)).into()) .await?; "All indexer settings updated".to_owned() } else { @@ -420,7 +420,7 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, RadarrEditCommand> for RadarrEditCommandH }; self .network - .handle_network_event((RadarrEvent::EditCollection(Some(edit_collection_params))).into()) + .handle_network_event(RadarrEvent::EditCollection(Some(edit_collection_params)).into()) .await?; "Collection updated".to_owned() } @@ -461,7 +461,7 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, RadarrEditCommand> for RadarrEditCommandH self .network - .handle_network_event((RadarrEvent::EditIndexer(Some(edit_indexer_params))).into()) + .handle_network_event(RadarrEvent::EditIndexer(Some(edit_indexer_params)).into()) .await?; "Indexer updated".to_owned() } @@ -488,7 +488,7 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, RadarrEditCommand> for RadarrEditCommandH self .network - .handle_network_event((RadarrEvent::EditMovie(Some(edit_movie_params))).into()) + .handle_network_event(RadarrEvent::EditMovie(Some(edit_movie_params)).into()) .await?; "Movie Updated".to_owned() } diff --git a/src/cli/radarr/get_command_handler.rs b/src/cli/radarr/get_command_handler.rs index 4df2595..4fe2830 100644 --- a/src/cli/radarr/get_command_handler.rs +++ b/src/cli/radarr/get_command_handler.rs @@ -76,42 +76,42 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, RadarrGetCommand> for RadarrGetCommandHan RadarrGetCommand::AllIndexerSettings => { let resp = self .network - .handle_network_event((RadarrEvent::GetAllIndexerSettings).into()) + .handle_network_event(RadarrEvent::GetAllIndexerSettings.into()) .await?; serde_json::to_string_pretty(&resp)? } RadarrGetCommand::HostConfig => { let resp = self .network - .handle_network_event((RadarrEvent::GetHostConfig).into()) + .handle_network_event(RadarrEvent::GetHostConfig.into()) .await?; serde_json::to_string_pretty(&resp)? } RadarrGetCommand::MovieDetails { movie_id } => { let resp = self .network - .handle_network_event((RadarrEvent::GetMovieDetails(Some(movie_id))).into()) + .handle_network_event(RadarrEvent::GetMovieDetails(Some(movie_id)).into()) .await?; serde_json::to_string_pretty(&resp)? } RadarrGetCommand::MovieHistory { movie_id } => { let resp = self .network - .handle_network_event((RadarrEvent::GetMovieHistory(Some(movie_id))).into()) + .handle_network_event(RadarrEvent::GetMovieHistory(Some(movie_id)).into()) .await?; serde_json::to_string_pretty(&resp)? } RadarrGetCommand::SecurityConfig => { let resp = self .network - .handle_network_event((RadarrEvent::GetSecurityConfig).into()) + .handle_network_event(RadarrEvent::GetSecurityConfig.into()) .await?; serde_json::to_string_pretty(&resp)? } RadarrGetCommand::SystemStatus => { let resp = self .network - .handle_network_event((RadarrEvent::GetStatus).into()) + .handle_network_event(RadarrEvent::GetStatus.into()) .await?; serde_json::to_string_pretty(&resp)? } diff --git a/src/cli/radarr/list_command_handler.rs b/src/cli/radarr/list_command_handler.rs index 0fc4dfb..d2b9019 100644 --- a/src/cli/radarr/list_command_handler.rs +++ b/src/cli/radarr/list_command_handler.rs @@ -91,28 +91,28 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, RadarrListCommand> for RadarrListCommandH RadarrListCommand::Blocklist => { let resp = self .network - .handle_network_event((RadarrEvent::GetBlocklist).into()) + .handle_network_event(RadarrEvent::GetBlocklist.into()) .await?; serde_json::to_string_pretty(&resp)? } RadarrListCommand::Collections => { let resp = self .network - .handle_network_event((RadarrEvent::GetCollections).into()) + .handle_network_event(RadarrEvent::GetCollections.into()) .await?; serde_json::to_string_pretty(&resp)? } RadarrListCommand::Downloads => { let resp = self .network - .handle_network_event((RadarrEvent::GetDownloads).into()) + .handle_network_event(RadarrEvent::GetDownloads.into()) .await?; serde_json::to_string_pretty(&resp)? } RadarrListCommand::Indexers => { let resp = self .network - .handle_network_event((RadarrEvent::GetIndexers).into()) + .handle_network_event(RadarrEvent::GetIndexers.into()) .await?; serde_json::to_string_pretty(&resp)? } @@ -136,56 +136,56 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, RadarrListCommand> for RadarrListCommandH RadarrListCommand::Movies => { let resp = self .network - .handle_network_event((RadarrEvent::GetMovies).into()) + .handle_network_event(RadarrEvent::GetMovies.into()) .await?; serde_json::to_string_pretty(&resp)? } RadarrListCommand::MovieCredits { movie_id } => { let resp = self .network - .handle_network_event((RadarrEvent::GetMovieCredits(Some(movie_id))).into()) + .handle_network_event(RadarrEvent::GetMovieCredits(Some(movie_id)).into()) .await?; serde_json::to_string_pretty(&resp)? } RadarrListCommand::QualityProfiles => { let resp = self .network - .handle_network_event((RadarrEvent::GetQualityProfiles).into()) + .handle_network_event(RadarrEvent::GetQualityProfiles.into()) .await?; serde_json::to_string_pretty(&resp)? } RadarrListCommand::QueuedEvents => { let resp = self .network - .handle_network_event((RadarrEvent::GetQueuedEvents).into()) + .handle_network_event(RadarrEvent::GetQueuedEvents.into()) .await?; serde_json::to_string_pretty(&resp)? } RadarrListCommand::RootFolders => { let resp = self .network - .handle_network_event((RadarrEvent::GetRootFolders).into()) + .handle_network_event(RadarrEvent::GetRootFolders.into()) .await?; serde_json::to_string_pretty(&resp)? } RadarrListCommand::Tags => { let resp = self .network - .handle_network_event((RadarrEvent::GetTags).into()) + .handle_network_event(RadarrEvent::GetTags.into()) .await?; serde_json::to_string_pretty(&resp)? } RadarrListCommand::Tasks => { let resp = self .network - .handle_network_event((RadarrEvent::GetTasks).into()) + .handle_network_event(RadarrEvent::GetTasks.into()) .await?; serde_json::to_string_pretty(&resp)? } RadarrListCommand::Updates => { let resp = self .network - .handle_network_event((RadarrEvent::GetUpdates).into()) + .handle_network_event(RadarrEvent::GetUpdates.into()) .await?; serde_json::to_string_pretty(&resp)? } diff --git a/src/cli/radarr/mod.rs b/src/cli/radarr/mod.rs index 2bb9e34..cd64155 100644 --- a/src/cli/radarr/mod.rs +++ b/src/cli/radarr/mod.rs @@ -193,7 +193,7 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, RadarrCommand> for RadarrCliHandler<'a, ' .await?; let resp = self .network - .handle_network_event((RadarrEvent::ClearBlocklist).into()) + .handle_network_event(RadarrEvent::ClearBlocklist.into()) .await?; serde_json::to_string_pretty(&resp)? } @@ -209,7 +209,7 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, RadarrCommand> for RadarrCliHandler<'a, ' }; let resp = self .network - .handle_network_event((RadarrEvent::DownloadRelease(Some(params))).into()) + .handle_network_event(RadarrEvent::DownloadRelease(Some(params)).into()) .await?; serde_json::to_string_pretty(&resp)? } @@ -217,42 +217,42 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, RadarrCommand> for RadarrCliHandler<'a, ' println!("Searching for releases. This may take a minute..."); let resp = self .network - .handle_network_event((RadarrEvent::GetReleases(Some(movie_id))).into()) + .handle_network_event(RadarrEvent::GetReleases(Some(movie_id)).into()) .await?; serde_json::to_string_pretty(&resp)? } RadarrCommand::SearchNewMovie { query } => { let resp = self .network - .handle_network_event((RadarrEvent::SearchNewMovie(Some(query))).into()) + .handle_network_event(RadarrEvent::SearchNewMovie(Some(query)).into()) .await?; serde_json::to_string_pretty(&resp)? } RadarrCommand::StartTask { task_name } => { let resp = self .network - .handle_network_event((RadarrEvent::StartTask(Some(task_name))).into()) + .handle_network_event(RadarrEvent::StartTask(Some(task_name)).into()) .await?; serde_json::to_string_pretty(&resp)? } RadarrCommand::TestIndexer { indexer_id } => { let resp = self .network - .handle_network_event((RadarrEvent::TestIndexer(Some(indexer_id))).into()) + .handle_network_event(RadarrEvent::TestIndexer(Some(indexer_id)).into()) .await?; serde_json::to_string_pretty(&resp)? } RadarrCommand::TestAllIndexers => { let resp = self .network - .handle_network_event((RadarrEvent::TestAllIndexers).into()) + .handle_network_event(RadarrEvent::TestAllIndexers.into()) .await?; serde_json::to_string_pretty(&resp)? } RadarrCommand::TriggerAutomaticSearch { movie_id } => { let resp = self .network - .handle_network_event((RadarrEvent::TriggerAutomaticSearch(Some(movie_id))).into()) + .handle_network_event(RadarrEvent::TriggerAutomaticSearch(Some(movie_id)).into()) .await?; serde_json::to_string_pretty(&resp)? } diff --git a/src/cli/radarr/refresh_command_handler.rs b/src/cli/radarr/refresh_command_handler.rs index 201be01..3101a98 100644 --- a/src/cli/radarr/refresh_command_handler.rs +++ b/src/cli/radarr/refresh_command_handler.rs @@ -67,28 +67,28 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, RadarrRefreshCommand> RadarrRefreshCommand::AllMovies => { let resp = self .network - .handle_network_event((RadarrEvent::UpdateAllMovies).into()) + .handle_network_event(RadarrEvent::UpdateAllMovies.into()) .await?; serde_json::to_string_pretty(&resp)? } RadarrRefreshCommand::Collections => { let resp = self .network - .handle_network_event((RadarrEvent::UpdateCollections).into()) + .handle_network_event(RadarrEvent::UpdateCollections.into()) .await?; serde_json::to_string_pretty(&resp)? } RadarrRefreshCommand::Downloads => { let resp = self .network - .handle_network_event((RadarrEvent::UpdateDownloads).into()) + .handle_network_event(RadarrEvent::UpdateDownloads.into()) .await?; serde_json::to_string_pretty(&resp)? } RadarrRefreshCommand::Movie { movie_id } => { let resp = self .network - .handle_network_event((RadarrEvent::UpdateAndScan(Some(movie_id))).into()) + .handle_network_event(RadarrEvent::UpdateAndScan(Some(movie_id)).into()) .await?; serde_json::to_string_pretty(&resp)? } diff --git a/src/cli/sonarr/delete_command_handler.rs b/src/cli/sonarr/delete_command_handler.rs index 0e1cd55..038830e 100644 --- a/src/cli/sonarr/delete_command_handler.rs +++ b/src/cli/sonarr/delete_command_handler.rs @@ -74,28 +74,28 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, SonarrDeleteCommand> for SonarrDeleteComm SonarrDeleteCommand::BlocklistItem { blocklist_item_id } => { let resp = self .network - .handle_network_event((SonarrEvent::DeleteBlocklistItem(Some(blocklist_item_id))).into()) + .handle_network_event(SonarrEvent::DeleteBlocklistItem(Some(blocklist_item_id)).into()) .await?; serde_json::to_string_pretty(&resp)? } SonarrDeleteCommand::Download { download_id } => { let resp = self .network - .handle_network_event((SonarrEvent::DeleteDownload(Some(download_id))).into()) + .handle_network_event(SonarrEvent::DeleteDownload(Some(download_id)).into()) .await?; serde_json::to_string_pretty(&resp)? } SonarrDeleteCommand::Indexer { indexer_id } => { let resp = self .network - .handle_network_event((SonarrEvent::DeleteIndexer(Some(indexer_id))).into()) + .handle_network_event(SonarrEvent::DeleteIndexer(Some(indexer_id)).into()) .await?; serde_json::to_string_pretty(&resp)? } SonarrDeleteCommand::RootFolder { root_folder_id } => { let resp = self .network - .handle_network_event((SonarrEvent::DeleteRootFolder(Some(root_folder_id))).into()) + .handle_network_event(SonarrEvent::DeleteRootFolder(Some(root_folder_id)).into()) .await?; serde_json::to_string_pretty(&resp)? } diff --git a/src/cli/sonarr/get_command_handler.rs b/src/cli/sonarr/get_command_handler.rs index 091ef87..37e3101 100644 --- a/src/cli/sonarr/get_command_handler.rs +++ b/src/cli/sonarr/get_command_handler.rs @@ -76,42 +76,42 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, SonarrGetCommand> for SonarrGetCommandHan SonarrGetCommand::AllIndexerSettings => { let resp = self .network - .handle_network_event((SonarrEvent::GetAllIndexerSettings).into()) + .handle_network_event(SonarrEvent::GetAllIndexerSettings.into()) .await?; serde_json::to_string_pretty(&resp)? } SonarrGetCommand::EpisodeDetails { episode_id } => { let resp = self .network - .handle_network_event((SonarrEvent::GetEpisodeDetails(Some(episode_id))).into()) + .handle_network_event(SonarrEvent::GetEpisodeDetails(Some(episode_id)).into()) .await?; serde_json::to_string_pretty(&resp)? } SonarrGetCommand::HostConfig => { let resp = self .network - .handle_network_event((SonarrEvent::GetHostConfig).into()) + .handle_network_event(SonarrEvent::GetHostConfig.into()) .await?; serde_json::to_string_pretty(&resp)? } SonarrGetCommand::SecurityConfig => { let resp = self .network - .handle_network_event((SonarrEvent::GetSecurityConfig).into()) + .handle_network_event(SonarrEvent::GetSecurityConfig.into()) .await?; serde_json::to_string_pretty(&resp)? } SonarrGetCommand::SeriesDetails { series_id } => { let resp = self .network - .handle_network_event((SonarrEvent::GetSeriesDetails(Some(series_id))).into()) + .handle_network_event(SonarrEvent::GetSeriesDetails(Some(series_id)).into()) .await?; serde_json::to_string_pretty(&resp)? } SonarrGetCommand::SystemStatus => { let resp = self .network - .handle_network_event((SonarrEvent::GetStatus).into()) + .handle_network_event(SonarrEvent::GetStatus.into()) .await?; serde_json::to_string_pretty(&resp)? } diff --git a/src/cli/sonarr/list_command_handler.rs b/src/cli/sonarr/list_command_handler.rs index 92ec8c9..2e2b85e 100644 --- a/src/cli/sonarr/list_command_handler.rs +++ b/src/cli/sonarr/list_command_handler.rs @@ -61,9 +61,9 @@ pub enum SonarrListCommand { QualityProfiles, #[command(about = "List all queued events")] QueuedEvents, - #[command(about = "List all series in your Sonarr library")] #[command(about = "List all root folders in Sonarr")] RootFolders, + #[command(about = "List all series in your Sonarr library")] Series, #[command(about = "Fetch all history events for the series with the given ID")] SeriesHistory { @@ -74,6 +74,8 @@ pub enum SonarrListCommand { )] series_id: i64, }, + #[command(about = "List all Sonarr tags")] + Tags, } impl From for Command { @@ -106,42 +108,42 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, SonarrListCommand> for SonarrListCommandH SonarrListCommand::Blocklist => { let resp = self .network - .handle_network_event((SonarrEvent::GetBlocklist).into()) + .handle_network_event(SonarrEvent::GetBlocklist.into()) .await?; serde_json::to_string_pretty(&resp)? } SonarrListCommand::Downloads => { let resp = self .network - .handle_network_event((SonarrEvent::GetDownloads).into()) + .handle_network_event(SonarrEvent::GetDownloads.into()) .await?; serde_json::to_string_pretty(&resp)? } SonarrListCommand::Episodes { series_id } => { let resp = self .network - .handle_network_event((SonarrEvent::GetEpisodes(Some(series_id))).into()) + .handle_network_event(SonarrEvent::GetEpisodes(Some(series_id)).into()) .await?; serde_json::to_string_pretty(&resp)? } SonarrListCommand::EpisodeHistory { episode_id } => { let resp = self .network - .handle_network_event((SonarrEvent::GetEpisodeHistory(Some(episode_id))).into()) + .handle_network_event(SonarrEvent::GetEpisodeHistory(Some(episode_id)).into()) .await?; serde_json::to_string_pretty(&resp)? } SonarrListCommand::History { events: items } => { let resp = self .network - .handle_network_event((SonarrEvent::GetHistory(Some(items))).into()) + .handle_network_event(SonarrEvent::GetHistory(Some(items)).into()) .await?; serde_json::to_string_pretty(&resp)? } SonarrListCommand::Indexers => { let resp = self .network - .handle_network_event((SonarrEvent::GetIndexers).into()) + .handle_network_event(SonarrEvent::GetIndexers.into()) .await?; serde_json::to_string_pretty(&resp)? } @@ -165,35 +167,42 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, SonarrListCommand> for SonarrListCommandH SonarrListCommand::QualityProfiles => { let resp = self .network - .handle_network_event((SonarrEvent::GetQualityProfiles).into()) + .handle_network_event(SonarrEvent::GetQualityProfiles.into()) .await?; serde_json::to_string_pretty(&resp)? } SonarrListCommand::QueuedEvents => { let resp = self .network - .handle_network_event((SonarrEvent::GetQueuedEvents).into()) + .handle_network_event(SonarrEvent::GetQueuedEvents.into()) .await?; serde_json::to_string_pretty(&resp)? } SonarrListCommand::RootFolders => { let resp = self .network - .handle_network_event((SonarrEvent::GetRootFolders).into()) + .handle_network_event(SonarrEvent::GetRootFolders.into()) .await?; serde_json::to_string_pretty(&resp)? } SonarrListCommand::Series => { let resp = self .network - .handle_network_event((SonarrEvent::ListSeries).into()) + .handle_network_event(SonarrEvent::ListSeries.into()) .await?; serde_json::to_string_pretty(&resp)? } SonarrListCommand::SeriesHistory { series_id } => { let resp = self .network - .handle_network_event((SonarrEvent::GetSeriesHistory(Some(series_id))).into()) + .handle_network_event(SonarrEvent::GetSeriesHistory(Some(series_id)).into()) + .await?; + serde_json::to_string_pretty(&resp)? + } + SonarrListCommand::Tags => { + let resp = self + .network + .handle_network_event(SonarrEvent::GetTags.into()) .await?; serde_json::to_string_pretty(&resp)? } diff --git a/src/cli/sonarr/list_command_handler_tests.rs b/src/cli/sonarr/list_command_handler_tests.rs index b8ed298..41d8a1f 100644 --- a/src/cli/sonarr/list_command_handler_tests.rs +++ b/src/cli/sonarr/list_command_handler_tests.rs @@ -31,7 +31,8 @@ mod tests { "quality-profiles", "indexers", "queued-events", - "root-folders" + "root-folders", + "tags" )] subcommand: &str, ) { @@ -202,6 +203,7 @@ mod tests { #[case(SonarrListCommand::QueuedEvents, SonarrEvent::GetQueuedEvents)] #[case(SonarrListCommand::RootFolders, SonarrEvent::GetRootFolders)] #[case(SonarrListCommand::Series, SonarrEvent::ListSeries)] + #[case(SonarrListCommand::Tags, SonarrEvent::GetTags)] #[tokio::test] async fn test_handle_list_command( #[case] list_command: SonarrListCommand, From a881d1f33a08c37309533814bca1ebff9281e3de Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Fri, 22 Nov 2024 15:54:11 -0700 Subject: [PATCH 058/119] feat(network): Support for listing disk space on a Sonarr instance --- src/app/app_tests.rs | 2 +- src/app/radarr/mod.rs | 2 +- src/app/radarr/radarr_tests.rs | 4 +- src/cli/sonarr/delete_command_handler.rs | 12 +++++ .../sonarr/delete_command_handler_tests.rs | 51 +++++++++++++++++++ src/models/radarr_models.rs | 13 +---- src/models/servarr_data/radarr/radarr_data.rs | 4 +- src/models/servarr_data/sonarr/sonarr_data.rs | 4 +- .../servarr_data/sonarr/sonarr_data_tests.rs | 1 + src/models/servarr_models.rs | 9 ++++ src/models/sonarr_models.rs | 6 ++- src/models/sonarr_models_tests.rs | 16 +++++- src/network/radarr_network.rs | 16 +++--- src/network/radarr_network_tests.rs | 8 +-- src/network/sonarr_network.rs | 22 +++++++- src/network/sonarr_network_tests.rs | 50 +++++++++++++++++- src/ui/radarr_ui/mod.rs | 4 +- 17 files changed, 185 insertions(+), 39 deletions(-) diff --git a/src/app/app_tests.rs b/src/app/app_tests.rs index 8975ea0..2fb02a0 100644 --- a/src/app/app_tests.rs +++ b/src/app/app_tests.rs @@ -213,7 +213,7 @@ mod tests { ); assert_eq!( sync_network_rx.recv().await.unwrap(), - RadarrEvent::GetOverview.into() + RadarrEvent::GetDiskSpace.into() ); assert_eq!( sync_network_rx.recv().await.unwrap(), diff --git a/src/app/radarr/mod.rs b/src/app/radarr/mod.rs index 3ffc43a..1c7f181 100644 --- a/src/app/radarr/mod.rs +++ b/src/app/radarr/mod.rs @@ -179,7 +179,7 @@ impl<'a> App<'a> { .dispatch_network_event(RadarrEvent::GetDownloads.into()) .await; self - .dispatch_network_event(RadarrEvent::GetOverview.into()) + .dispatch_network_event(RadarrEvent::GetDiskSpace.into()) .await; self .dispatch_network_event(RadarrEvent::GetStatus.into()) diff --git a/src/app/radarr/radarr_tests.rs b/src/app/radarr/radarr_tests.rs index 7a2ab5a..e4be8f1 100644 --- a/src/app/radarr/radarr_tests.rs +++ b/src/app/radarr/radarr_tests.rs @@ -511,7 +511,7 @@ mod tests { ); assert_eq!( sync_network_rx.recv().await.unwrap(), - RadarrEvent::GetOverview.into() + RadarrEvent::GetDiskSpace.into() ); assert_eq!( sync_network_rx.recv().await.unwrap(), @@ -544,7 +544,7 @@ mod tests { ); assert_eq!( sync_network_rx.recv().await.unwrap(), - RadarrEvent::GetOverview.into() + RadarrEvent::GetDiskSpace.into() ); assert_eq!( sync_network_rx.recv().await.unwrap(), diff --git a/src/cli/sonarr/delete_command_handler.rs b/src/cli/sonarr/delete_command_handler.rs index 038830e..32a9d28 100644 --- a/src/cli/sonarr/delete_command_handler.rs +++ b/src/cli/sonarr/delete_command_handler.rs @@ -42,6 +42,11 @@ pub enum SonarrDeleteCommand { #[arg(long, help = "The ID of the root folder to delete", required = true)] root_folder_id: i64, }, + #[command(about = "Delete the tag with the specified ID")] + Tag { + #[arg(long, help = "The ID of the tag to delete", required = true)] + tag_id: i64, + }, } impl From for Command { @@ -99,6 +104,13 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, SonarrDeleteCommand> for SonarrDeleteComm .await?; serde_json::to_string_pretty(&resp)? } + SonarrDeleteCommand::Tag { tag_id } => { + let resp = self + .network + .handle_network_event(SonarrEvent::DeleteTag(tag_id).into()) + .await?; + serde_json::to_string_pretty(&resp)? + } }; Ok(resp) diff --git a/src/cli/sonarr/delete_command_handler_tests.rs b/src/cli/sonarr/delete_command_handler_tests.rs index 9953225..8c66a4e 100644 --- a/src/cli/sonarr/delete_command_handler_tests.rs +++ b/src/cli/sonarr/delete_command_handler_tests.rs @@ -156,6 +156,31 @@ mod tests { assert_eq!(delete_command, expected_args); } } + + #[test] + fn test_delete_tag_requires_arguments() { + let result = Cli::command().try_get_matches_from(["managarr", "sonarr", "delete", "tag"]); + + assert!(result.is_err()); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_delete_tag_success() { + let expected_args = SonarrDeleteCommand::Tag { tag_id: 1 }; + + let result = Cli::try_parse_from(["managarr", "sonarr", "delete", "tag", "--tag-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 { @@ -283,5 +308,31 @@ mod tests { assert!(result.is_ok()); } + + #[tokio::test] + async fn test_handle_delete_tag_command() { + let expected_tag_id = 1; + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::( + SonarrEvent::DeleteTag(expected_tag_id).into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Sonarr(SonarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::default())); + let delete_tag_command = SonarrDeleteCommand::Tag { tag_id: 1 }; + + let result = + SonarrDeleteCommandHandler::with(&app_arc, delete_tag_command, &mut mock_network) + .handle() + .await; + + assert!(result.is_ok()); + } } } diff --git a/src/models/radarr_models.rs b/src/models/radarr_models.rs index 9cf06cf..981f69c 100644 --- a/src/models/radarr_models.rs +++ b/src/models/radarr_models.rs @@ -10,8 +10,8 @@ use strum_macros::EnumIter; use crate::{models::HorizontallyScrollableText, serde_enum_from}; use super::servarr_models::{ - HostConfig, Indexer, Language, LogResponse, QualityProfile, QualityWrapper, QueueEvent, Release, - RootFolder, SecurityConfig, Tag, + DiskSpace, HostConfig, Indexer, Language, LogResponse, QualityProfile, QualityWrapper, + QueueEvent, Release, RootFolder, SecurityConfig, Tag, }; use super::{EnumDisplayStyle, Serdeable}; @@ -149,15 +149,6 @@ pub struct DeleteMovieParams { pub add_list_exclusion: bool, } -#[derive(Serialize, Deserialize, Debug, Clone, Eq, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct DiskSpace { - #[serde(deserialize_with = "super::from_i64")] - pub free_space: i64, - #[serde(deserialize_with = "super::from_i64")] - pub total_space: i64, -} - #[derive(Derivative, Serialize, Deserialize, Debug, Default, Clone, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct DownloadRecord { diff --git a/src/models/servarr_data/radarr/radarr_data.rs b/src/models/servarr_data/radarr/radarr_data.rs index 4f7e6a4..1e2ada9 100644 --- a/src/models/servarr_data/radarr/radarr_data.rs +++ b/src/models/servarr_data/radarr/radarr_data.rs @@ -6,14 +6,14 @@ use crate::app::radarr::radarr_context_clues::{ SYSTEM_CONTEXT_CLUES, }; use crate::models::radarr_models::{ - AddMovieSearchResult, BlocklistItem, Collection, CollectionMovie, DiskSpace, DownloadRecord, + AddMovieSearchResult, BlocklistItem, Collection, CollectionMovie, DownloadRecord, IndexerSettings, Movie, Task, }; use crate::models::servarr_data::radarr::modals::{ AddMovieModal, EditCollectionModal, EditIndexerModal, EditMovieModal, IndexerTestResultModalItem, MovieDetailsModal, }; -use crate::models::servarr_models::{Indexer, QueueEvent, RootFolder}; +use crate::models::servarr_models::{DiskSpace, Indexer, QueueEvent, RootFolder}; use crate::models::stateful_list::StatefulList; use crate::models::stateful_table::StatefulTable; use crate::models::{ diff --git a/src/models/servarr_data/sonarr/sonarr_data.rs b/src/models/servarr_data/sonarr/sonarr_data.rs index 211582c..c18849b 100644 --- a/src/models/servarr_data/sonarr/sonarr_data.rs +++ b/src/models/servarr_data/sonarr/sonarr_data.rs @@ -3,7 +3,7 @@ use chrono::{DateTime, Utc}; use strum::EnumIter; use crate::models::{ - servarr_models::{Indexer, QueueEvent, RootFolder}, + servarr_models::{DiskSpace, Indexer, QueueEvent, RootFolder}, sonarr_models::{ BlocklistItem, DownloadRecord, IndexerSettings, Season, Series, SonarrHistoryItem, }, @@ -21,6 +21,7 @@ mod sonarr_data_tests; pub struct SonarrData { pub blocklist: StatefulTable, pub downloads: StatefulTable, + pub disk_space_vec: Vec, pub edit_root_folder: Option, pub history: StatefulTable, pub indexers: StatefulTable, @@ -43,6 +44,7 @@ impl Default for SonarrData { SonarrData { blocklist: StatefulTable::default(), downloads: StatefulTable::default(), + disk_space_vec: Vec::new(), edit_root_folder: None, history: StatefulTable::default(), indexers: StatefulTable::default(), diff --git a/src/models/servarr_data/sonarr/sonarr_data_tests.rs b/src/models/servarr_data/sonarr/sonarr_data_tests.rs index 5c174be..8fc8cc5 100644 --- a/src/models/servarr_data/sonarr/sonarr_data_tests.rs +++ b/src/models/servarr_data/sonarr/sonarr_data_tests.rs @@ -36,6 +36,7 @@ mod tests { assert!(sonarr_data.blocklist.is_empty()); assert!(sonarr_data.downloads.is_empty()); + assert!(sonarr_data.disk_space_vec.is_empty()); assert!(sonarr_data.edit_root_folder.is_none()); assert!(sonarr_data.history.is_empty()); assert!(sonarr_data.indexers.is_empty()); diff --git a/src/models/servarr_models.rs b/src/models/servarr_models.rs index 68f78ef..82cdc9e 100644 --- a/src/models/servarr_models.rs +++ b/src/models/servarr_models.rs @@ -74,6 +74,15 @@ impl Display for CertificateValidation { } } +#[derive(Serialize, Deserialize, Debug, Clone, Eq, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct DiskSpace { + #[serde(deserialize_with = "super::from_i64")] + pub free_space: i64, + #[serde(deserialize_with = "super::from_i64")] + pub total_space: i64, +} + #[derive(Default, Deserialize, Serialize, Debug, Clone, Eq, PartialEq)] #[serde(rename_all = "camelCase")] pub struct HostConfig { diff --git a/src/models/sonarr_models.rs b/src/models/sonarr_models.rs index 72d5f17..4a8ea3e 100644 --- a/src/models/sonarr_models.rs +++ b/src/models/sonarr_models.rs @@ -11,8 +11,8 @@ use crate::serde_enum_from; use super::{ servarr_models::{ - HostConfig, Indexer, Language, LogResponse, QualityProfile, QualityWrapper, QueueEvent, - Release, RootFolder, SecurityConfig, Tag, + DiskSpace, HostConfig, Indexer, Language, LogResponse, QualityProfile, QualityWrapper, + QueueEvent, Release, RootFolder, SecurityConfig, Tag, }, EnumDisplayStyle, HorizontallyScrollableText, Serdeable, }; @@ -394,6 +394,7 @@ pub struct SonarrHistoryItem { pub enum SonarrSerdeable { Value(Value), DownloadsResponse(DownloadsResponse), + DiskSpaces(Vec), Episode(Episode), Episodes(Vec), HostConfig(HostConfig), @@ -431,6 +432,7 @@ serde_enum_from!( SonarrSerdeable { Value(Value), DownloadsResponse(DownloadsResponse), + DiskSpaces(Vec), Episode(Episode), Episodes(Vec), HostConfig(HostConfig), diff --git a/src/models/sonarr_models_tests.rs b/src/models/sonarr_models_tests.rs index 363ad4f..44f9446 100644 --- a/src/models/sonarr_models_tests.rs +++ b/src/models/sonarr_models_tests.rs @@ -5,8 +5,8 @@ mod tests { use crate::models::{ servarr_models::{ - HostConfig, Indexer, Log, LogResponse, QualityProfile, QueueEvent, Release, RootFolder, - SecurityConfig, Tag, + DiskSpace, HostConfig, Indexer, Log, LogResponse, QualityProfile, QueueEvent, Release, + RootFolder, SecurityConfig, Tag, }, sonarr_models::{ BlocklistItem, BlocklistResponse, DownloadRecord, DownloadsResponse, Episode, @@ -291,6 +291,18 @@ mod tests { ); } + #[test] + fn test_sonarr_serdeable_from_disk_spaces() { + let disk_spaces = vec![DiskSpace { + free_space: 1, + total_space: 1, + }]; + + let sonarr_serdeable: SonarrSerdeable = disk_spaces.clone().into(); + + assert_eq!(sonarr_serdeable, SonarrSerdeable::DiskSpaces(disk_spaces)); + } + #[test] fn test_sonarr_serdeable_from_log_response() { let log_response = LogResponse { diff --git a/src/network/radarr_network.rs b/src/network/radarr_network.rs index 717fe32..1f70e65 100644 --- a/src/network/radarr_network.rs +++ b/src/network/radarr_network.rs @@ -8,7 +8,7 @@ use urlencoding::encode; use crate::models::radarr_models::{ AddMovieBody, AddMovieSearchResult, AddOptions, BlocklistResponse, Collection, CollectionMovie, - CommandBody, Credit, CreditType, DeleteMovieParams, DiskSpace, DownloadRecord, DownloadsResponse, + CommandBody, Credit, CreditType, DeleteMovieParams, DownloadRecord, DownloadsResponse, EditCollectionParams, EditIndexerParams, EditMovieParams, IndexerSettings, IndexerTestResult, Movie, MovieCommandBody, MovieHistoryItem, RadarrSerdeable, ReleaseDownloadBody, SystemStatus, Task, TaskName, Update, @@ -19,8 +19,8 @@ use crate::models::servarr_data::radarr::modals::{ }; use crate::models::servarr_data::radarr::radarr_data::ActiveRadarrBlock; use crate::models::servarr_models::{ - AddRootFolderBody, HostConfig, Indexer, LogResponse, QualityProfile, QueueEvent, Release, - RootFolder, SecurityConfig, Tag, + AddRootFolderBody, DiskSpace, HostConfig, Indexer, LogResponse, QualityProfile, QueueEvent, + Release, RootFolder, SecurityConfig, Tag, }; use crate::models::stateful_table::StatefulTable; use crate::models::{HorizontallyScrollableText, Route, Scrollable, ScrollableText}; @@ -61,7 +61,7 @@ pub enum RadarrEvent { GetMovieDetails(Option), GetMovieHistory(Option), GetMovies, - GetOverview, + GetDiskSpace, GetQualityProfiles, GetQueuedEvents, GetReleases(Option), @@ -107,7 +107,7 @@ impl NetworkResource for RadarrEvent { RadarrEvent::SearchNewMovie(_) => "/movie/lookup", RadarrEvent::GetMovieCredits(_) => "/credit", RadarrEvent::GetMovieHistory(_) => "/history/movie", - RadarrEvent::GetOverview => "/diskspace", + RadarrEvent::GetDiskSpace => "/diskspace", RadarrEvent::GetQualityProfiles => "/qualityprofile", RadarrEvent::GetReleases(_) | RadarrEvent::DownloadRelease(_) => "/release", RadarrEvent::AddRootFolder(_) @@ -220,7 +220,7 @@ impl<'a, 'b> Network<'a, 'b> { .await .map(RadarrSerdeable::from), RadarrEvent::GetMovies => self.get_movies().await.map(RadarrSerdeable::from), - RadarrEvent::GetOverview => self.get_diskspace().await.map(RadarrSerdeable::from), + RadarrEvent::GetDiskSpace => self.get_radarr_diskspace().await.map(RadarrSerdeable::from), RadarrEvent::GetQualityProfiles => self .get_radarr_quality_profiles() .await @@ -1368,9 +1368,9 @@ impl<'a, 'b> Network<'a, 'b> { .await } - async fn get_diskspace(&mut self) -> Result> { + async fn get_radarr_diskspace(&mut self) -> Result> { info!("Fetching Radarr disk space"); - let event = RadarrEvent::GetOverview; + let event = RadarrEvent::GetDiskSpace; let request_props = self .request_props_from(event, RequestMethod::Get, None::<()>, None, None) diff --git a/src/network/radarr_network_tests.rs b/src/network/radarr_network_tests.rs index bfadf03..a36c4dc 100644 --- a/src/network/radarr_network_tests.rs +++ b/src/network/radarr_network_tests.rs @@ -222,7 +222,7 @@ mod test { #[case(RadarrEvent::SearchNewMovie(None), "/movie/lookup")] #[case(RadarrEvent::GetMovieCredits(None), "/credit")] #[case(RadarrEvent::GetMovieHistory(None), "/history/movie")] - #[case(RadarrEvent::GetOverview, "/diskspace")] + #[case(RadarrEvent::GetDiskSpace, "/diskspace")] #[case(RadarrEvent::GetQualityProfiles, "/qualityprofile")] #[case(RadarrEvent::GetStatus, "/system/status")] #[case(RadarrEvent::GetTasks, "/system/task")] @@ -262,7 +262,7 @@ mod test { } #[tokio::test] - async fn test_handle_get_diskspace_event() { + async fn test_handle_get_radarr_diskspace_event() { let (async_server, app_arc, _server) = mock_servarr_api( RequestMethod::Get, None, @@ -277,7 +277,7 @@ mod test { } ])), None, - RadarrEvent::GetOverview, + RadarrEvent::GetDiskSpace, None, None, ) @@ -295,7 +295,7 @@ mod test { ]; if let RadarrSerdeable::DiskSpaces(disk_space) = network - .handle_radarr_event(RadarrEvent::GetOverview) + .handle_radarr_event(RadarrEvent::GetDiskSpace) .await .unwrap() { diff --git a/src/network/sonarr_network.rs b/src/network/sonarr_network.rs index 22341b5..680016e 100644 --- a/src/network/sonarr_network.rs +++ b/src/network/sonarr_network.rs @@ -10,8 +10,8 @@ use crate::{ sonarr_data::ActiveSonarrBlock, }, servarr_models::{ - AddRootFolderBody, HostConfig, Indexer, LogResponse, QualityProfile, QueueEvent, Release, - RootFolder, SecurityConfig, Tag, + AddRootFolderBody, DiskSpace, HostConfig, Indexer, LogResponse, QualityProfile, QueueEvent, + Release, RootFolder, SecurityConfig, Tag, }, sonarr_models::{ BlocklistResponse, DownloadRecord, DownloadsResponse, Episode, IndexerSettings, Series, @@ -49,6 +49,7 @@ pub enum SonarrEvent { GetEpisodes(Option), GetEpisodeHistory(Option), GetLogs(Option), + GetDiskSpace, GetQualityProfiles, GetQueuedEvents, GetRootFolders, @@ -77,6 +78,7 @@ impl NetworkResource for SonarrEvent { SonarrEvent::GetHostConfig | SonarrEvent::GetSecurityConfig => "/config/host", SonarrEvent::GetIndexers | SonarrEvent::DeleteIndexer(_) => "/indexer", SonarrEvent::GetLogs(_) => "/log", + SonarrEvent::GetDiskSpace => "/diskspace", SonarrEvent::GetQualityProfiles => "/qualityprofile", SonarrEvent::GetQueuedEvents => "/command", SonarrEvent::GetRootFolders @@ -163,6 +165,7 @@ impl<'a, 'b> Network<'a, 'b> { .get_sonarr_logs(events) .await .map(SonarrSerdeable::from), + SonarrEvent::GetDiskSpace => self.get_sonarr_diskspace().await.map(SonarrSerdeable::from), SonarrEvent::GetQualityProfiles => self .get_sonarr_quality_profiles() .await @@ -843,6 +846,21 @@ impl<'a, 'b> Network<'a, 'b> { .await } + async fn get_sonarr_diskspace(&mut self) -> Result> { + info!("Fetching Sonarr disk space"); + let event = SonarrEvent::GetDiskSpace; + + let request_props = self + .request_props_from(event, RequestMethod::Get, None::<()>, None, None) + .await; + + self + .handle_request::<(), Vec>(request_props, |disk_space_vec, mut app| { + app.data.sonarr_data.disk_space_vec = disk_space_vec; + }) + .await + } + async fn get_sonarr_quality_profiles(&mut self) -> Result> { info!("Fetching Sonarr quality profiles"); let event = SonarrEvent::GetQualityProfiles; diff --git a/src/network/sonarr_network_tests.rs b/src/network/sonarr_network_tests.rs index 1401935..6737d39 100644 --- a/src/network/sonarr_network_tests.rs +++ b/src/network/sonarr_network_tests.rs @@ -17,7 +17,7 @@ mod test { use crate::models::servarr_data::sonarr::modals::{EpisodeDetailsModal, SeasonDetailsModal}; use crate::models::servarr_data::sonarr::sonarr_data::ActiveSonarrBlock; use crate::models::servarr_models::{ - HostConfig, Indexer, IndexerField, Language, LogResponse, Quality, QualityProfile, + DiskSpace, HostConfig, Indexer, IndexerField, Language, LogResponse, Quality, QualityProfile, QualityWrapper, QueueEvent, Release, RootFolder, SecurityConfig, Tag, }; use crate::models::sonarr_models::SystemStatus; @@ -212,6 +212,7 @@ mod test { #[case(SonarrEvent::DeleteBlocklistItem(None), "/blocklist")] #[case(SonarrEvent::HealthCheck, "/health")] #[case(SonarrEvent::GetBlocklist, "/blocklist?page=1&pageSize=10000")] + #[case(SonarrEvent::GetDiskSpace, "/diskspace")] #[case(SonarrEvent::GetSeriesHistory(None), "/history/series")] #[case(SonarrEvent::GetLogs(Some(500)), "/log")] #[case(SonarrEvent::GetQualityProfiles, "/qualityprofile")] @@ -784,6 +785,53 @@ mod test { } } + #[tokio::test] + async fn test_handle_get_sonarr_diskspace_event() { + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(json!([ + { + "freeSpace": 1111, + "totalSpace": 2222, + }, + { + "freeSpace": 3333, + "totalSpace": 4444 + } + ])), + None, + SonarrEvent::GetDiskSpace, + None, + None, + ) + .await; + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + let disk_space_vec = vec![ + DiskSpace { + free_space: 1111, + total_space: 2222, + }, + DiskSpace { + free_space: 3333, + total_space: 4444, + }, + ]; + + if let SonarrSerdeable::DiskSpaces(disk_space) = network + .handle_sonarr_event(SonarrEvent::GetDiskSpace) + .await + .unwrap() + { + async_server.assert_async().await; + assert_eq!( + app_arc.lock().await.data.sonarr_data.disk_space_vec, + disk_space_vec + ); + assert_eq!(disk_space, disk_space_vec); + } + } + #[tokio::test] async fn test_handle_get_sonarr_healthcheck_event() { let (async_server, app_arc, _server) = mock_servarr_api( diff --git a/src/ui/radarr_ui/mod.rs b/src/ui/radarr_ui/mod.rs index ddc2422..d19fabc 100644 --- a/src/ui/radarr_ui/mod.rs +++ b/src/ui/radarr_ui/mod.rs @@ -9,9 +9,9 @@ use ratatui::Frame; use crate::app::App; use crate::logos::RADARR_LOGO; -use crate::models::radarr_models::{DiskSpace, DownloadRecord, Movie}; +use crate::models::radarr_models::{DownloadRecord, Movie}; use crate::models::servarr_data::radarr::radarr_data::RadarrData; -use crate::models::servarr_models::RootFolder; +use crate::models::servarr_models::{DiskSpace, RootFolder}; use crate::models::Route; use crate::ui::draw_tabs; use crate::ui::radarr_ui::blocklist::BlocklistUi; From df3cf70682eb4c15391a02dd501628610a9c31ae Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Fri, 22 Nov 2024 15:57:05 -0700 Subject: [PATCH 059/119] feat(cli): Support for listing the available disk space for all provisioned root folders in both Radarr and Sonarr --- src/cli/radarr/list_command_handler.rs | 9 +++++++++ src/cli/radarr/list_command_handler_tests.rs | 2 ++ src/cli/sonarr/list_command_handler.rs | 9 +++++++++ src/cli/sonarr/list_command_handler_tests.rs | 2 ++ 4 files changed, 22 insertions(+) diff --git a/src/cli/radarr/list_command_handler.rs b/src/cli/radarr/list_command_handler.rs index d2b9019..7d0962e 100644 --- a/src/cli/radarr/list_command_handler.rs +++ b/src/cli/radarr/list_command_handler.rs @@ -24,6 +24,8 @@ pub enum RadarrListCommand { Collections, #[command(about = "List all active downloads in Radarr")] Downloads, + #[command(about = "List disk space details for all provisioned root folders in Radarr")] + DiskSpace, #[command(about = "List all Radarr indexers")] Indexers, #[command(about = "Fetch Radarr logs")] @@ -109,6 +111,13 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, RadarrListCommand> for RadarrListCommandH .await?; serde_json::to_string_pretty(&resp)? } + RadarrListCommand::DiskSpace => { + let resp = self + .network + .handle_network_event(RadarrEvent::GetDiskSpace.into()) + .await?; + serde_json::to_string_pretty(&resp)? + } RadarrListCommand::Indexers => { let resp = self .network diff --git a/src/cli/radarr/list_command_handler_tests.rs b/src/cli/radarr/list_command_handler_tests.rs index f922de9..080e0df 100644 --- a/src/cli/radarr/list_command_handler_tests.rs +++ b/src/cli/radarr/list_command_handler_tests.rs @@ -29,6 +29,7 @@ mod tests { "blocklist", "collections", "downloads", + "disk-space", "indexers", "movies", "quality-profiles", @@ -121,6 +122,7 @@ mod tests { #[case(RadarrListCommand::Blocklist, RadarrEvent::GetBlocklist)] #[case(RadarrListCommand::Collections, RadarrEvent::GetCollections)] #[case(RadarrListCommand::Downloads, RadarrEvent::GetDownloads)] + #[case(RadarrListCommand::DiskSpace, RadarrEvent::GetDiskSpace)] #[case(RadarrListCommand::Indexers, RadarrEvent::GetIndexers)] #[case(RadarrListCommand::Movies, RadarrEvent::GetMovies)] #[case(RadarrListCommand::QualityProfiles, RadarrEvent::GetQualityProfiles)] diff --git a/src/cli/sonarr/list_command_handler.rs b/src/cli/sonarr/list_command_handler.rs index 2e2b85e..ff35417 100644 --- a/src/cli/sonarr/list_command_handler.rs +++ b/src/cli/sonarr/list_command_handler.rs @@ -22,6 +22,8 @@ pub enum SonarrListCommand { Blocklist, #[command(about = "List all active downloads in Sonarr")] Downloads, + #[command(about = "List disk space details for all provisioned root folders in Sonarr")] + DiskSpace, #[command(about = "List the episodes for the series with the given ID")] Episodes { #[arg( @@ -119,6 +121,13 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, SonarrListCommand> for SonarrListCommandH .await?; serde_json::to_string_pretty(&resp)? } + SonarrListCommand::DiskSpace => { + let resp = self + .network + .handle_network_event(SonarrEvent::GetDiskSpace.into()) + .await?; + serde_json::to_string_pretty(&resp)? + } SonarrListCommand::Episodes { series_id } => { let resp = self .network diff --git a/src/cli/sonarr/list_command_handler_tests.rs b/src/cli/sonarr/list_command_handler_tests.rs index 41d8a1f..9d4bab5 100644 --- a/src/cli/sonarr/list_command_handler_tests.rs +++ b/src/cli/sonarr/list_command_handler_tests.rs @@ -28,6 +28,7 @@ mod tests { "blocklist", "series", "downloads", + "disk-space", "quality-profiles", "indexers", "queued-events", @@ -198,6 +199,7 @@ mod tests { #[rstest] #[case(SonarrListCommand::Blocklist, SonarrEvent::GetBlocklist)] #[case(SonarrListCommand::Downloads, SonarrEvent::GetDownloads)] + #[case(SonarrListCommand::DiskSpace, SonarrEvent::GetDiskSpace)] #[case(SonarrListCommand::Indexers, SonarrEvent::GetIndexers)] #[case(SonarrListCommand::QualityProfiles, SonarrEvent::GetQualityProfiles)] #[case(SonarrListCommand::QueuedEvents, SonarrEvent::GetQueuedEvents)] From 9476caa392821024dd4438917f3f79d1c3eeab58 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Fri, 22 Nov 2024 16:13:35 -0700 Subject: [PATCH 060/119] feat(network): Support for marking a Sonarr history item as failed --- src/models/servarr_data/sonarr/sonarr_data.rs | 4 +-- src/network/sonarr_network.rs | 25 +++++++++++++++++++ src/network/sonarr_network_tests.rs | 25 +++++++++++++++++++ 3 files changed, 52 insertions(+), 2 deletions(-) diff --git a/src/models/servarr_data/sonarr/sonarr_data.rs b/src/models/servarr_data/sonarr/sonarr_data.rs index c18849b..532b969 100644 --- a/src/models/servarr_data/sonarr/sonarr_data.rs +++ b/src/models/servarr_data/sonarr/sonarr_data.rs @@ -114,8 +114,8 @@ pub enum ActiveSonarrBlock { ManualSeasonSearch, ManualSeasonSearchConfirmPrompt, ManualSeasonSearchSortPrompt, - MarkHistoryItemAsFailureConfirmPrompt, - MarkHistoryItemAsFailurePrompt, + MarkHistoryItemAsFailedConfirmPrompt, + MarkHistoryItemAsFailedPrompt, RootFolders, SearchEpisodes, SearchEpisodesError, diff --git a/src/network/sonarr_network.rs b/src/network/sonarr_network.rs index 680016e..557ddab 100644 --- a/src/network/sonarr_network.rs +++ b/src/network/sonarr_network.rs @@ -62,6 +62,7 @@ pub enum SonarrEvent { GetTags, HealthCheck, ListSeries, + MarkHistoryItemAsFailed(i64), } impl NetworkResource for SonarrEvent { @@ -89,6 +90,7 @@ impl NetworkResource for SonarrEvent { SonarrEvent::GetStatus => "/system/status", SonarrEvent::HealthCheck => "/health", SonarrEvent::ListSeries | SonarrEvent::GetSeriesDetails(_) => "/series", + SonarrEvent::MarkHistoryItemAsFailed(_) => "/history/failed", } } } @@ -205,6 +207,10 @@ impl<'a, 'b> Network<'a, 'b> { .await .map(SonarrSerdeable::from), SonarrEvent::ListSeries => self.list_series().await.map(SonarrSerdeable::from), + SonarrEvent::MarkHistoryItemAsFailed(history_item_id) => self + .mark_sonarr_history_item_as_failed(history_item_id) + .await + .map(SonarrSerdeable::from), } } @@ -1150,6 +1156,25 @@ impl<'a, 'b> Network<'a, 'b> { .await } + async fn mark_sonarr_history_item_as_failed(&mut self, history_item_id: i64) -> Result { + info!("Marking the Sonarr history item with ID: {history_item_id} as 'failed'"); + let event = SonarrEvent::MarkHistoryItemAsFailed(history_item_id); + + let request_props = self + .request_props_from( + event, + RequestMethod::Post, + None, + Some(format!("/{history_item_id}")), + None, + ) + .await; + + self + .handle_request::<(), Value>(request_props, |_, _| ()) + .await + } + async fn extract_series_id(&mut self, series_id: Option) -> (i64, String) { let series_id = if let Some(id) = series_id { id diff --git a/src/network/sonarr_network_tests.rs b/src/network/sonarr_network_tests.rs index 6737d39..343ec2d 100644 --- a/src/network/sonarr_network_tests.rs +++ b/src/network/sonarr_network_tests.rs @@ -217,6 +217,7 @@ mod test { #[case(SonarrEvent::GetLogs(Some(500)), "/log")] #[case(SonarrEvent::GetQualityProfiles, "/qualityprofile")] #[case(SonarrEvent::GetStatus, "/system/status")] + #[case(SonarrEvent::MarkHistoryItemAsFailed(0), "/history/failed")] fn test_resource(#[case] event: SonarrEvent, #[case] expected_uri: String) { assert_str_eq!(event.resource(), expected_uri); } @@ -3674,6 +3675,30 @@ mod test { } } + #[tokio::test] + async fn test_handle_mark_sonarr_history_item_as_failed_event() { + let expected_history_item_id = 1; + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Post, + None, + Some(json!({})), + None, + SonarrEvent::MarkHistoryItemAsFailed(expected_history_item_id), + Some("/1"), + None, + ) + .await; + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + assert!(network + .handle_sonarr_event(SonarrEvent::MarkHistoryItemAsFailed( + expected_history_item_id + )) + .await + .is_ok()); + async_server.assert_async().await; + } + #[tokio::test] async fn test_extract_series_id() { let app_arc = Arc::new(Mutex::new(App::default())); From 539ad75fe6126db9d538494a91513a6d32c97e71 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Fri, 22 Nov 2024 16:21:43 -0700 Subject: [PATCH 061/119] feat(cli): Support for marking a Sonarr history item as 'failed' --- src/cli/sonarr/mod.rs | 16 ++++++++ src/cli/sonarr/sonarr_command_tests.rs | 55 ++++++++++++++++++++++++++ 2 files changed, 71 insertions(+) diff --git a/src/cli/sonarr/mod.rs b/src/cli/sonarr/mod.rs index 73fd53d..658c56e 100644 --- a/src/cli/sonarr/mod.rs +++ b/src/cli/sonarr/mod.rs @@ -48,6 +48,15 @@ pub enum SonarrCommand { List(SonarrListCommand), #[command(about = "Clear the blocklist")] ClearBlocklist, + #[command(about = "Mark the Sonarr history item with the given ID as 'failed'")] + MarkHistoryItemAsFailed { + #[arg( + long, + help = "The Sonarr ID of the history item you wish to mark as 'failed'", + required = true + )] + history_item_id: i64, + }, #[command(about = "Trigger a manual search of releases for the episode with the given ID")] ManualEpisodeSearch { #[arg( @@ -130,6 +139,13 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, SonarrCommand> for SonarrCliHandler<'a, ' .await?; serde_json::to_string_pretty(&resp)? } + SonarrCommand::MarkHistoryItemAsFailed { history_item_id } => { + let _ = self + .network + .handle_network_event(SonarrEvent::MarkHistoryItemAsFailed(history_item_id).into()) + .await?; + "Sonarr history item marked as 'failed'".to_owned() + } SonarrCommand::ManualEpisodeSearch { episode_id } => { println!("Searching for episode releases. This may take a minute..."); let resp = self diff --git a/src/cli/sonarr/sonarr_command_tests.rs b/src/cli/sonarr/sonarr_command_tests.rs index 0d72ec9..be2f4b5 100644 --- a/src/cli/sonarr/sonarr_command_tests.rs +++ b/src/cli/sonarr/sonarr_command_tests.rs @@ -28,6 +28,31 @@ mod tests { assert!(result.is_ok()); } + #[rstest] + fn test_mark_history_item_as_failed_requires_history_item_id() { + let result = + Cli::command().try_get_matches_from(["managarr", "sonarr", "mark-history-item-as-failed"]); + + assert!(result.is_err()); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[rstest] + fn test_mark_history_item_as_failed_requirements_satisfied() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "sonarr", + "mark-history-item-as-failed", + "--history-item-id", + "1", + ]); + + assert!(result.is_ok()); + } + #[rstest] fn test_manual_season_search_requires_series_id() { let result = Cli::command().try_get_matches_from([ @@ -160,6 +185,36 @@ mod tests { assert!(result.is_ok()); } + #[tokio::test] + async fn test_mark_history_item_as_failed_command() { + let expected_history_item_id = 1; + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::( + SonarrEvent::MarkHistoryItemAsFailed(expected_history_item_id).into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Sonarr(SonarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::default())); + let mark_history_item_as_failed_command = + SonarrCommand::MarkHistoryItemAsFailed { history_item_id: 1 }; + + let result = SonarrCliHandler::with( + &app_arc, + mark_history_item_as_failed_command, + &mut mock_network, + ) + .handle() + .await; + + assert!(result.is_ok()); + } + #[tokio::test] async fn test_manual_episode_search_command() { let expected_episode_id = 1; From c54bd2bab0629b2529748a36521549f340951c13 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Fri, 22 Nov 2024 16:35:39 -0700 Subject: [PATCH 062/119] feat(network): Support for fetching all Sonarr tasks --- src/cli/radarr/mod.rs | 4 +- src/cli/radarr/radarr_command_tests.rs | 8 +- .../system/system_details_handler_tests.rs | 16 ++-- .../system/system_handler_tests.rs | 80 +++++++++++++++---- src/models/radarr_models.rs | 12 +-- src/models/radarr_models_tests.rs | 8 +- src/models/servarr_data/radarr/radarr_data.rs | 4 +- src/models/servarr_data/sonarr/sonarr_data.rs | 4 +- .../servarr_data/sonarr/sonarr_data_tests.rs | 1 + src/models/sonarr_models.rs | 40 ++++++++++ src/models/sonarr_models_tests.rs | 22 ++++- src/network/radarr_network.rs | 14 ++-- src/network/radarr_network_tests.rs | 20 ++--- src/network/sonarr_network.rs | 20 ++++- src/network/sonarr_network_tests.rs | 68 +++++++++++++++- src/ui/radarr_ui/system/mod.rs | 6 +- src/ui/radarr_ui/system/system_details_ui.rs | 4 +- 17 files changed, 267 insertions(+), 64 deletions(-) diff --git a/src/cli/radarr/mod.rs b/src/cli/radarr/mod.rs index cd64155..623dd5a 100644 --- a/src/cli/radarr/mod.rs +++ b/src/cli/radarr/mod.rs @@ -12,7 +12,7 @@ use tokio::sync::Mutex; use crate::app::App; use crate::cli::CliCommandHandler; -use crate::models::radarr_models::{ReleaseDownloadBody, TaskName}; +use crate::models::radarr_models::{RadarrTaskName, ReleaseDownloadBody}; use crate::network::radarr_network::RadarrEvent; use crate::network::NetworkTrait; use anyhow::Result; @@ -107,7 +107,7 @@ pub enum RadarrCommand { value_enum, required = true )] - task_name: TaskName, + task_name: RadarrTaskName, }, #[command( about = "Test the indexer with the given ID. Note that a successful test returns an empty JSON body; i.e. '{}'" diff --git a/src/cli/radarr/radarr_command_tests.rs b/src/cli/radarr/radarr_command_tests.rs index 88bf862..fcb59d2 100644 --- a/src/cli/radarr/radarr_command_tests.rs +++ b/src/cli/radarr/radarr_command_tests.rs @@ -261,8 +261,8 @@ mod tests { }, models::{ radarr_models::{ - BlocklistItem, BlocklistResponse, IndexerSettings, RadarrSerdeable, ReleaseDownloadBody, - TaskName, + BlocklistItem, BlocklistResponse, IndexerSettings, RadarrSerdeable, RadarrTaskName, + ReleaseDownloadBody, }, Serdeable, }, @@ -389,7 +389,7 @@ mod tests { #[tokio::test] async fn test_start_task_command() { - let expected_task_name = TaskName::ApplicationCheckUpdate; + let expected_task_name = RadarrTaskName::ApplicationCheckUpdate; let mut mock_network = MockNetworkTrait::new(); mock_network .expect_handle_network_event() @@ -404,7 +404,7 @@ mod tests { }); let app_arc = Arc::new(Mutex::new(App::default())); let start_task_command = RadarrCommand::StartTask { - task_name: TaskName::ApplicationCheckUpdate, + task_name: RadarrTaskName::ApplicationCheckUpdate, }; let result = RadarrCliHandler::with(&app_arc, start_task_command, &mut mock_network) diff --git a/src/handlers/radarr_handlers/system/system_details_handler_tests.rs b/src/handlers/radarr_handlers/system/system_details_handler_tests.rs index cde9d2f..65c9497 100644 --- a/src/handlers/radarr_handlers/system/system_details_handler_tests.rs +++ b/src/handlers/radarr_handlers/system/system_details_handler_tests.rs @@ -8,7 +8,7 @@ mod tests { use crate::event::Key; use crate::handlers::radarr_handlers::system::system_details_handler::SystemDetailsHandler; use crate::handlers::KeyEventHandler; - use crate::models::radarr_models::Task; + use crate::models::radarr_models::RadarrTask; use crate::models::servarr_data::radarr::radarr_data::{ ActiveRadarrBlock, SYSTEM_DETAILS_BLOCKS, }; @@ -74,7 +74,7 @@ mod tests { .data .radarr_data .tasks - .set_items(simple_stateful_iterable_vec!(Task, String, name)); + .set_items(simple_stateful_iterable_vec!(RadarrTask, String, name)); SystemDetailsHandler::with(&key, &mut app, &ActiveRadarrBlock::SystemTasks, &None).handle(); @@ -102,7 +102,7 @@ mod tests { .data .radarr_data .tasks - .set_items(simple_stateful_iterable_vec!(Task, String, name)); + .set_items(simple_stateful_iterable_vec!(RadarrTask, String, name)); SystemDetailsHandler::with(&key, &mut app, &ActiveRadarrBlock::SystemTasks, &None).handle(); @@ -318,7 +318,7 @@ mod tests { .data .radarr_data .tasks - .set_items(extended_stateful_iterable_vec!(Task, String, name)); + .set_items(extended_stateful_iterable_vec!(RadarrTask, String, name)); SystemDetailsHandler::with( &DEFAULT_KEYBINDINGS.end.key, @@ -357,7 +357,7 @@ mod tests { .data .radarr_data .tasks - .set_items(extended_stateful_iterable_vec!(Task, String, name)); + .set_items(extended_stateful_iterable_vec!(RadarrTask, String, name)); SystemDetailsHandler::with( &DEFAULT_KEYBINDINGS.end.key, @@ -789,7 +789,11 @@ mod tests { app.is_loading = is_ready; app.push_navigation_stack(ActiveRadarrBlock::System.into()); app.push_navigation_stack(ActiveRadarrBlock::SystemTasks.into()); - app.data.radarr_data.tasks.set_items(vec![Task::default()]); + app + .data + .radarr_data + .tasks + .set_items(vec![RadarrTask::default()]); SystemDetailsHandler::with(&ESC_KEY, &mut app, &ActiveRadarrBlock::SystemTasks, &None) .handle(); diff --git a/src/handlers/radarr_handlers/system/system_handler_tests.rs b/src/handlers/radarr_handlers/system/system_handler_tests.rs index b1c864a..03b3aec 100644 --- a/src/handlers/radarr_handlers/system/system_handler_tests.rs +++ b/src/handlers/radarr_handlers/system/system_handler_tests.rs @@ -9,7 +9,7 @@ mod tests { use crate::event::Key; use crate::handlers::radarr_handlers::system::SystemHandler; use crate::handlers::KeyEventHandler; - use crate::models::radarr_models::Task; + use crate::models::radarr_models::RadarrTask; use crate::models::servarr_data::radarr::radarr_data::{ ActiveRadarrBlock, SYSTEM_DETAILS_BLOCKS, }; @@ -105,7 +105,11 @@ mod tests { .radarr_data .queued_events .set_items(vec![QueueEvent::default()]); - app.data.radarr_data.tasks.set_items(vec![Task::default()]); + app + .data + .radarr_data + .tasks + .set_items(vec![RadarrTask::default()]); SystemHandler::with( &DEFAULT_KEYBINDINGS.update.key, @@ -135,7 +139,11 @@ mod tests { .radarr_data .queued_events .set_items(vec![QueueEvent::default()]); - app.data.radarr_data.tasks.set_items(vec![Task::default()]); + app + .data + .radarr_data + .tasks + .set_items(vec![RadarrTask::default()]); SystemHandler::with( &DEFAULT_KEYBINDINGS.update.key, @@ -160,7 +168,11 @@ mod tests { .radarr_data .queued_events .set_items(vec![QueueEvent::default()]); - app.data.radarr_data.tasks.set_items(vec![Task::default()]); + app + .data + .radarr_data + .tasks + .set_items(vec![RadarrTask::default()]); SystemHandler::with( &DEFAULT_KEYBINDINGS.events.key, @@ -190,7 +202,11 @@ mod tests { .radarr_data .queued_events .set_items(vec![QueueEvent::default()]); - app.data.radarr_data.tasks.set_items(vec![Task::default()]); + app + .data + .radarr_data + .tasks + .set_items(vec![RadarrTask::default()]); SystemHandler::with( &DEFAULT_KEYBINDINGS.events.key, @@ -215,7 +231,11 @@ mod tests { .radarr_data .queued_events .set_items(vec![QueueEvent::default()]); - app.data.radarr_data.tasks.set_items(vec![Task::default()]); + app + .data + .radarr_data + .tasks + .set_items(vec![RadarrTask::default()]); app.push_navigation_stack(ActiveRadarrBlock::System.into()); SystemHandler::with( @@ -244,7 +264,11 @@ mod tests { .radarr_data .queued_events .set_items(vec![QueueEvent::default()]); - app.data.radarr_data.tasks.set_items(vec![Task::default()]); + app + .data + .radarr_data + .tasks + .set_items(vec![RadarrTask::default()]); app.push_navigation_stack(ActiveRadarrBlock::System.into()); SystemHandler::with( @@ -271,7 +295,11 @@ mod tests { .radarr_data .queued_events .set_items(vec![QueueEvent::default()]); - app.data.radarr_data.tasks.set_items(vec![Task::default()]); + app + .data + .radarr_data + .tasks + .set_items(vec![RadarrTask::default()]); SystemHandler::with( &DEFAULT_KEYBINDINGS.logs.key, @@ -309,7 +337,11 @@ mod tests { .radarr_data .queued_events .set_items(vec![QueueEvent::default()]); - app.data.radarr_data.tasks.set_items(vec![Task::default()]); + app + .data + .radarr_data + .tasks + .set_items(vec![RadarrTask::default()]); SystemHandler::with( &DEFAULT_KEYBINDINGS.logs.key, @@ -335,7 +367,11 @@ mod tests { .radarr_data .queued_events .set_items(vec![QueueEvent::default()]); - app.data.radarr_data.tasks.set_items(vec![Task::default()]); + app + .data + .radarr_data + .tasks + .set_items(vec![RadarrTask::default()]); SystemHandler::with( &DEFAULT_KEYBINDINGS.tasks.key, @@ -365,7 +401,11 @@ mod tests { .radarr_data .queued_events .set_items(vec![QueueEvent::default()]); - app.data.radarr_data.tasks.set_items(vec![Task::default()]); + app + .data + .radarr_data + .tasks + .set_items(vec![RadarrTask::default()]); SystemHandler::with( &DEFAULT_KEYBINDINGS.tasks.key, @@ -430,7 +470,11 @@ mod tests { fn test_system_handler_is_not_ready_when_logs_is_empty() { let mut app = App::default(); app.is_loading = false; - app.data.radarr_data.tasks.set_items(vec![Task::default()]); + app + .data + .radarr_data + .tasks + .set_items(vec![RadarrTask::default()]); app .data .radarr_data @@ -473,7 +517,11 @@ mod tests { let mut app = App::default(); app.is_loading = false; app.data.radarr_data.logs.set_items(vec!["test".into()]); - app.data.radarr_data.tasks.set_items(vec![Task::default()]); + app + .data + .radarr_data + .tasks + .set_items(vec![RadarrTask::default()]); let system_handler = SystemHandler::with( &DEFAULT_KEYBINDINGS.update.key, @@ -490,7 +538,11 @@ mod tests { let mut app = App::default(); app.is_loading = false; app.data.radarr_data.logs.set_items(vec!["test".into()]); - app.data.radarr_data.tasks.set_items(vec![Task::default()]); + app + .data + .radarr_data + .tasks + .set_items(vec![RadarrTask::default()]); app .data .radarr_data diff --git a/src/models/radarr_models.rs b/src/models/radarr_models.rs index 981f69c..ecb7b16 100644 --- a/src/models/radarr_models.rs +++ b/src/models/radarr_models.rs @@ -435,9 +435,9 @@ pub struct SystemStatus { #[derive(Default, Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] #[serde(rename_all = "camelCase")] -pub struct Task { +pub struct RadarrTask { pub name: String, - pub task_name: TaskName, + pub task_name: RadarrTaskName, #[serde(deserialize_with = "super::from_i64")] pub interval: i64, pub last_execution: DateTime, @@ -447,7 +447,7 @@ pub struct Task { #[derive(Default, Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Copy, ValueEnum)] #[serde(rename_all = "PascalCase")] -pub enum TaskName { +pub enum RadarrTaskName { #[default] ApplicationCheckUpdate, Backup, @@ -462,7 +462,7 @@ pub enum TaskName { RssSync, } -impl Display for TaskName { +impl Display for RadarrTaskName { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { let task_name = serde_json::to_string(&self) .expect("Unable to serialize task name") @@ -514,7 +514,7 @@ pub enum RadarrSerdeable { SecurityConfig(SecurityConfig), SystemStatus(SystemStatus), Tags(Vec), - Tasks(Vec), + Tasks(Vec), Updates(Vec), AddMovieSearchResults(Vec), IndexerTestResults(Vec), @@ -555,7 +555,7 @@ serde_enum_from!( SecurityConfig(SecurityConfig), SystemStatus(SystemStatus), Tags(Vec), - Tasks(Vec), + Tasks(Vec), Updates(Vec), AddMovieSearchResults(Vec), IndexerTestResults(Vec), diff --git a/src/models/radarr_models_tests.rs b/src/models/radarr_models_tests.rs index eb07c6b..4afe5a1 100644 --- a/src/models/radarr_models_tests.rs +++ b/src/models/radarr_models_tests.rs @@ -8,7 +8,7 @@ mod tests { AddMovieSearchResult, BlocklistItem, BlocklistResponse, Collection, Credit, DiskSpace, DownloadRecord, DownloadsResponse, Indexer, IndexerSettings, IndexerTestResult, MinimumAvailability, Monitor, Movie, MovieHistoryItem, QualityProfile, RadarrSerdeable, - Release, SystemStatus, Tag, Task, TaskName, Update, + RadarrTask, RadarrTaskName, Release, SystemStatus, Tag, Update, }, servarr_models::{HostConfig, Log, LogResponse, QueueEvent, RootFolder, SecurityConfig}, EnumDisplayStyle, Serdeable, @@ -17,7 +17,7 @@ mod tests { #[test] fn test_task_name_display() { assert_str_eq!( - TaskName::ApplicationCheckUpdate.to_string(), + RadarrTaskName::ApplicationCheckUpdate.to_string(), "ApplicationCheckUpdate" ); } @@ -383,9 +383,9 @@ mod tests { #[test] fn test_radarr_serdeable_from_tasks() { - let tasks = vec![Task { + let tasks = vec![RadarrTask { name: "test".to_owned(), - ..Task::default() + ..RadarrTask::default() }]; let radarr_serdeable: RadarrSerdeable = tasks.clone().into(); diff --git a/src/models/servarr_data/radarr/radarr_data.rs b/src/models/servarr_data/radarr/radarr_data.rs index 1e2ada9..483a1a0 100644 --- a/src/models/servarr_data/radarr/radarr_data.rs +++ b/src/models/servarr_data/radarr/radarr_data.rs @@ -7,7 +7,7 @@ use crate::app::radarr::radarr_context_clues::{ }; use crate::models::radarr_models::{ AddMovieSearchResult, BlocklistItem, Collection, CollectionMovie, DownloadRecord, - IndexerSettings, Movie, Task, + IndexerSettings, Movie, RadarrTask, }; use crate::models::servarr_data::radarr::modals::{ AddMovieModal, EditCollectionModal, EditIndexerModal, EditMovieModal, IndexerTestResultModalItem, @@ -48,7 +48,7 @@ pub struct RadarrData<'a> { pub collection_movies: StatefulTable, pub logs: StatefulList, pub log_details: StatefulList, - pub tasks: StatefulTable, + pub tasks: StatefulTable, pub queued_events: StatefulTable, pub updates: ScrollableText, pub main_tabs: TabState, diff --git a/src/models/servarr_data/sonarr/sonarr_data.rs b/src/models/servarr_data/sonarr/sonarr_data.rs index 532b969..db5461f 100644 --- a/src/models/servarr_data/sonarr/sonarr_data.rs +++ b/src/models/servarr_data/sonarr/sonarr_data.rs @@ -5,7 +5,7 @@ use strum::EnumIter; use crate::models::{ servarr_models::{DiskSpace, Indexer, QueueEvent, RootFolder}, sonarr_models::{ - BlocklistItem, DownloadRecord, IndexerSettings, Season, Series, SonarrHistoryItem, + BlocklistItem, DownloadRecord, IndexerSettings, Season, Series, SonarrHistoryItem, SonarrTask, }, stateful_list::StatefulList, stateful_table::StatefulTable, @@ -36,6 +36,7 @@ pub struct SonarrData { pub series_history: Option>, pub start_time: DateTime, pub tags_map: BiMap, + pub tasks: StatefulTable, pub version: String, } @@ -59,6 +60,7 @@ impl Default for SonarrData { series_history: None, start_time: DateTime::default(), tags_map: BiMap::default(), + tasks: StatefulTable::default(), version: String::new(), } } diff --git a/src/models/servarr_data/sonarr/sonarr_data_tests.rs b/src/models/servarr_data/sonarr/sonarr_data_tests.rs index 8fc8cc5..495a5af 100644 --- a/src/models/servarr_data/sonarr/sonarr_data_tests.rs +++ b/src/models/servarr_data/sonarr/sonarr_data_tests.rs @@ -51,6 +51,7 @@ mod tests { assert!(sonarr_data.series_history.is_none()); assert_eq!(sonarr_data.start_time, >::default()); assert!(sonarr_data.tags_map.is_empty()); + assert!(sonarr_data.tasks.is_empty()); assert!(sonarr_data.version.is_empty()); } } diff --git a/src/models/sonarr_models.rs b/src/models/sonarr_models.rs index 4a8ea3e..e5ad2a6 100644 --- a/src/models/sonarr_models.rs +++ b/src/models/sonarr_models.rs @@ -388,6 +388,44 @@ pub struct SonarrHistoryItem { pub data: SonarrHistoryData, } +#[derive(Default, Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct SonarrTask { + pub name: String, + pub task_name: SonarrTaskName, + #[serde(deserialize_with = "super::from_i64")] + pub interval: i64, + pub last_execution: DateTime, + pub last_duration: String, + pub next_execution: DateTime, +} + +#[derive(Default, Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Copy, ValueEnum)] +#[serde(rename_all = "PascalCase")] +pub enum SonarrTaskName { + #[default] + ApplicationUpdateCheck, + Backup, + CheckHealth, + CleanUpRecycleBin, + Housekeeping, + ImportListSync, + MessagingCleanup, + RefreshMonitoredDownloads, + RefreshSeries, + RssSync, + UpdateSceneMapping, +} + +impl Display for SonarrTaskName { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + let task_name = serde_json::to_string(&self) + .expect("Unable to serialize task name") + .replace('"', ""); + write!(f, "{task_name}") + } +} + #[derive(Clone, Serialize, Deserialize, Debug, PartialEq, Eq)] #[serde(untagged)] #[allow(clippy::large_enum_variant)] @@ -412,6 +450,7 @@ pub enum SonarrSerdeable { SystemStatus(SystemStatus), Tag(Tag), Tags(Vec), + Tasks(Vec), BlocklistResponse(BlocklistResponse), LogResponse(LogResponse), } @@ -450,6 +489,7 @@ serde_enum_from!( SystemStatus(SystemStatus), Tag(Tag), Tags(Vec), + Tasks(Vec), BlocklistResponse(BlocklistResponse), LogResponse(LogResponse), } diff --git a/src/models/sonarr_models_tests.rs b/src/models/sonarr_models_tests.rs index 44f9446..abf970a 100644 --- a/src/models/sonarr_models_tests.rs +++ b/src/models/sonarr_models_tests.rs @@ -11,7 +11,7 @@ mod tests { sonarr_models::{ BlocklistItem, BlocklistResponse, DownloadRecord, DownloadsResponse, Episode, IndexerSettings, Series, SeriesStatus, SeriesType, SonarrHistoryEventType, SonarrHistoryItem, - SonarrSerdeable, SystemStatus, + SonarrSerdeable, SonarrTask, SonarrTaskName, SystemStatus, }, EnumDisplayStyle, Serdeable, }; @@ -117,6 +117,14 @@ mod tests { ); } + #[test] + fn test_task_name_display() { + assert_str_eq!( + SonarrTaskName::ApplicationUpdateCheck.to_string(), + "ApplicationUpdateCheck" + ); + } + #[test] fn test_sonarr_serdeable_from() { let sonarr_serdeable = SonarrSerdeable::Value(json!({})); @@ -406,4 +414,16 @@ mod tests { assert_eq!(sonarr_serdeable, SonarrSerdeable::Tags(tags)); } + + #[test] + fn test_sonarr_serdeable_from_tasks() { + let tasks = vec![SonarrTask { + name: "test".to_owned(), + ..SonarrTask::default() + }]; + + let sonarr_serdeable: SonarrSerdeable = tasks.clone().into(); + + assert_eq!(sonarr_serdeable, SonarrSerdeable::Tasks(tasks)); + } } diff --git a/src/network/radarr_network.rs b/src/network/radarr_network.rs index 1f70e65..88bf9ea 100644 --- a/src/network/radarr_network.rs +++ b/src/network/radarr_network.rs @@ -10,8 +10,8 @@ use crate::models::radarr_models::{ AddMovieBody, AddMovieSearchResult, AddOptions, BlocklistResponse, Collection, CollectionMovie, CommandBody, Credit, CreditType, DeleteMovieParams, DownloadRecord, DownloadsResponse, EditCollectionParams, EditIndexerParams, EditMovieParams, IndexerSettings, IndexerTestResult, - Movie, MovieCommandBody, MovieHistoryItem, RadarrSerdeable, ReleaseDownloadBody, SystemStatus, - Task, TaskName, Update, + Movie, MovieCommandBody, MovieHistoryItem, RadarrSerdeable, RadarrTask, RadarrTaskName, + ReleaseDownloadBody, SystemStatus, Update, }; use crate::models::servarr_data::radarr::modals::{ AddMovieModal, EditCollectionModal, EditIndexerModal, EditMovieModal, IndexerTestResultModalItem, @@ -73,7 +73,7 @@ pub enum RadarrEvent { GetUpdates, HealthCheck, SearchNewMovie(Option), - StartTask(Option), + StartTask(Option), TestIndexer(Option), TestAllIndexers, TriggerAutomaticSearch(Option), @@ -243,7 +243,7 @@ impl<'a, 'b> Network<'a, 'b> { .map(RadarrSerdeable::from), RadarrEvent::GetStatus => self.get_radarr_status().await.map(RadarrSerdeable::from), RadarrEvent::GetTags => self.get_radarr_tags().await.map(RadarrSerdeable::from), - RadarrEvent::GetTasks => self.get_tasks().await.map(RadarrSerdeable::from), + RadarrEvent::GetTasks => self.get_radarr_tasks().await.map(RadarrSerdeable::from), RadarrEvent::GetUpdates => self.get_updates().await.map(RadarrSerdeable::from), RadarrEvent::HealthCheck => self .get_radarr_healthcheck() @@ -1855,7 +1855,7 @@ impl<'a, 'b> Network<'a, 'b> { .await } - async fn get_tasks(&mut self) -> Result> { + async fn get_radarr_tasks(&mut self) -> Result> { info!("Fetching Radarr tasks"); let event = RadarrEvent::GetTasks; @@ -1864,7 +1864,7 @@ impl<'a, 'b> Network<'a, 'b> { .await; self - .handle_request::<(), Vec>(request_props, |tasks_vec, mut app| { + .handle_request::<(), Vec>(request_props, |tasks_vec, mut app| { app.data.radarr_data.tasks.set_items(tasks_vec); }) .await @@ -2006,7 +2006,7 @@ impl<'a, 'b> Network<'a, 'b> { } } - async fn start_task(&mut self, task: Option) -> Result { + async fn start_task(&mut self, task: Option) -> Result { let event = RadarrEvent::StartTask(None); let task_name = if let Some(t_name) = task { t_name diff --git a/src/network/radarr_network_tests.rs b/src/network/radarr_network_tests.rs index a36c4dc..c4cc9f6 100644 --- a/src/network/radarr_network_tests.rs +++ b/src/network/radarr_network_tests.rs @@ -733,9 +733,9 @@ mod test { .data .radarr_data .tasks - .set_items(vec![Task { - task_name: TaskName::default(), - ..Task::default() + .set_items(vec![RadarrTask { + task_name: RadarrTaskName::default(), + ..RadarrTask::default() }]); let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); @@ -767,7 +767,7 @@ mod test { let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); if let RadarrSerdeable::Value(value) = network - .handle_radarr_event(RadarrEvent::StartTask(Some(TaskName::default()))) + .handle_radarr_event(RadarrEvent::StartTask(Some(RadarrTaskName::default()))) .await .unwrap() { @@ -2666,7 +2666,7 @@ mod test { } #[tokio::test] - async fn test_handle_get_tasks_event() { + async fn test_handle_get_radarr_tasks_event() { let tasks_json = json!([{ "name": "Application Check Update", "taskName": "ApplicationCheckUpdate", @@ -2683,20 +2683,20 @@ mod test { "nextExecution": "2023-05-20T21:29:16Z", "lastDuration": "00:00:00.5111547", }]); - let response: Vec = serde_json::from_value(tasks_json.clone()).unwrap(); + let response: Vec = serde_json::from_value(tasks_json.clone()).unwrap(); let timestamp = DateTime::from(DateTime::parse_from_rfc3339("2023-05-20T21:29:16Z").unwrap()); let expected_tasks = vec![ - Task { + RadarrTask { name: "Application Check Update".to_owned(), - task_name: TaskName::ApplicationCheckUpdate, + task_name: RadarrTaskName::ApplicationCheckUpdate, interval: 360, last_execution: timestamp, next_execution: timestamp, last_duration: "00:00:00.5111547".to_owned(), }, - Task { + RadarrTask { name: "Backup".to_owned(), - task_name: TaskName::Backup, + task_name: RadarrTaskName::Backup, interval: 10080, last_execution: timestamp, next_execution: timestamp, diff --git a/src/network/sonarr_network.rs b/src/network/sonarr_network.rs index 557ddab..f8029ff 100644 --- a/src/network/sonarr_network.rs +++ b/src/network/sonarr_network.rs @@ -15,7 +15,7 @@ use crate::{ }, sonarr_models::{ BlocklistResponse, DownloadRecord, DownloadsResponse, Episode, IndexerSettings, Series, - SonarrHistoryItem, SonarrHistoryWrapper, SonarrSerdeable, SystemStatus, + SonarrHistoryItem, SonarrHistoryWrapper, SonarrSerdeable, SonarrTask, SystemStatus, }, stateful_table::StatefulTable, HorizontallyScrollableText, Route, Scrollable, ScrollableText, @@ -60,6 +60,7 @@ pub enum SonarrEvent { GetSeriesHistory(Option), GetStatus, GetTags, + GetTasks, HealthCheck, ListSeries, MarkHistoryItemAsFailed(i64), @@ -88,6 +89,7 @@ impl NetworkResource for SonarrEvent { SonarrEvent::GetSeasonReleases(_) | SonarrEvent::GetEpisodeReleases(_) => "/release", SonarrEvent::GetSeriesHistory(_) => "/history/series", SonarrEvent::GetStatus => "/system/status", + SonarrEvent::GetTasks => "/system/task", SonarrEvent::HealthCheck => "/health", SonarrEvent::ListSeries | SonarrEvent::GetSeriesDetails(_) => "/series", SonarrEvent::MarkHistoryItemAsFailed(_) => "/history/failed", @@ -202,6 +204,7 @@ impl<'a, 'b> Network<'a, 'b> { .map(SonarrSerdeable::from), SonarrEvent::GetStatus => self.get_sonarr_status().await.map(SonarrSerdeable::from), SonarrEvent::GetTags => self.get_sonarr_tags().await.map(SonarrSerdeable::from), + SonarrEvent::GetTasks => self.get_sonarr_tasks().await.map(SonarrSerdeable::from), SonarrEvent::HealthCheck => self .get_sonarr_healthcheck() .await @@ -1156,6 +1159,21 @@ impl<'a, 'b> Network<'a, 'b> { .await } + async fn get_sonarr_tasks(&mut self) -> Result> { + info!("Fetching Sonarr tasks"); + let event = SonarrEvent::GetTasks; + + let request_props = self + .request_props_from(event, RequestMethod::Get, None::<()>, None, None) + .await; + + self + .handle_request::<(), Vec>(request_props, |tasks_vec, mut app| { + app.data.sonarr_data.tasks.set_items(tasks_vec); + }) + .await + } + async fn mark_sonarr_history_item_as_failed(&mut self, history_item_id: i64) -> Result { info!("Marking the Sonarr history item with ID: {history_item_id} as 'failed'"); let event = SonarrEvent::MarkHistoryItemAsFailed(history_item_id); diff --git a/src/network/sonarr_network_tests.rs b/src/network/sonarr_network_tests.rs index 343ec2d..9eddb75 100644 --- a/src/network/sonarr_network_tests.rs +++ b/src/network/sonarr_network_tests.rs @@ -20,13 +20,14 @@ mod test { DiskSpace, HostConfig, Indexer, IndexerField, Language, LogResponse, Quality, QualityProfile, QualityWrapper, QueueEvent, Release, RootFolder, SecurityConfig, Tag, }; - use crate::models::sonarr_models::SystemStatus; use crate::models::sonarr_models::{ BlocklistItem, DownloadRecord, DownloadsResponse, Episode, EpisodeFile, MediaInfo, + SonarrTaskName, }; use crate::models::sonarr_models::{ BlocklistResponse, SonarrHistoryData, SonarrHistoryItem, SonarrHistoryWrapper, }; + use crate::models::sonarr_models::{SonarrTask, SystemStatus}; use crate::models::stateful_table::StatefulTable; use crate::models::HorizontallyScrollableText; use crate::models::{sonarr_models::SonarrSerdeable, stateful_table::SortOption}; @@ -217,6 +218,7 @@ mod test { #[case(SonarrEvent::GetLogs(Some(500)), "/log")] #[case(SonarrEvent::GetQualityProfiles, "/qualityprofile")] #[case(SonarrEvent::GetStatus, "/system/status")] + #[case(SonarrEvent::GetTasks, "/system/task")] #[case(SonarrEvent::MarkHistoryItemAsFailed(0), "/history/failed")] fn test_resource(#[case] event: SonarrEvent, #[case] expected_uri: String) { assert_str_eq!(event.resource(), expected_uri); @@ -3675,6 +3677,70 @@ mod test { } } + #[tokio::test] + async fn test_handle_get_sonarr_tasks_event() { + let tasks_json = json!([{ + "name": "Application Update Check", + "taskName": "ApplicationUpdateCheck", + "interval": 360, + "lastExecution": "2023-05-20T21:29:16Z", + "nextExecution": "2023-05-20T21:29:16Z", + "lastDuration": "00:00:00.5111547", + }, + { + "name": "Backup", + "taskName": "Backup", + "interval": 10080, + "lastExecution": "2023-05-20T21:29:16Z", + "nextExecution": "2023-05-20T21:29:16Z", + "lastDuration": "00:00:00.5111547", + }]); + let response: Vec = serde_json::from_value(tasks_json.clone()).unwrap(); + let timestamp = DateTime::from(DateTime::parse_from_rfc3339("2023-05-20T21:29:16Z").unwrap()); + let expected_tasks = vec![ + SonarrTask { + name: "Application Update Check".to_owned(), + task_name: SonarrTaskName::ApplicationUpdateCheck, + interval: 360, + last_execution: timestamp, + next_execution: timestamp, + last_duration: "00:00:00.5111547".to_owned(), + }, + SonarrTask { + name: "Backup".to_owned(), + task_name: SonarrTaskName::Backup, + interval: 10080, + last_execution: timestamp, + next_execution: timestamp, + last_duration: "00:00:00.5111547".to_owned(), + }, + ]; + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(tasks_json), + None, + SonarrEvent::GetTasks, + None, + None, + ) + .await; + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + if let SonarrSerdeable::Tasks(tasks) = network + .handle_sonarr_event(SonarrEvent::GetTasks) + .await + .unwrap() + { + async_server.assert_async().await; + assert_eq!( + app_arc.lock().await.data.sonarr_data.tasks.items, + expected_tasks + ); + assert_eq!(tasks, response); + } + } + #[tokio::test] async fn test_handle_mark_sonarr_history_item_as_failed_event() { let expected_history_item_id = 1; diff --git a/src/ui/radarr_ui/system/mod.rs b/src/ui/radarr_ui/system/mod.rs index 5616649..c46653e 100644 --- a/src/ui/radarr_ui/system/mod.rs +++ b/src/ui/radarr_ui/system/mod.rs @@ -12,7 +12,7 @@ use ratatui::{ }; use crate::app::App; -use crate::models::radarr_models::Task; +use crate::models::radarr_models::RadarrTask; use crate::models::servarr_data::radarr::radarr_data::ActiveRadarrBlock; use crate::models::servarr_models::QueueEvent; use crate::ui::radarr_ui::radarr_ui_utils::{convert_to_minutes_hours_days, style_log_list_item}; @@ -91,7 +91,7 @@ pub(super) fn draw_system_ui_layout(f: &mut Frame<'_>, app: &mut App<'_>, area: } fn draw_tasks(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { - let tasks_row_mapping = |task: &Task| { + let tasks_row_mapping = |task: &RadarrTask| { let task_props = extract_task_props(task); Row::new(vec![ @@ -218,7 +218,7 @@ pub(super) struct TaskProps { pub(super) next_execution: String, } -pub(super) fn extract_task_props(task: &Task) -> TaskProps { +pub(super) fn extract_task_props(task: &RadarrTask) -> TaskProps { let interval = convert_to_minutes_hours_days(task.interval); let last_duration = &task.last_duration[..8]; let next_execution = diff --git a/src/ui/radarr_ui/system/system_details_ui.rs b/src/ui/radarr_ui/system/system_details_ui.rs index db4582a..cfc3c5c 100644 --- a/src/ui/radarr_ui/system/system_details_ui.rs +++ b/src/ui/radarr_ui/system/system_details_ui.rs @@ -6,7 +6,7 @@ use ratatui::Frame; use crate::app::context_clues::{build_context_clue_string, BARE_POPUP_CONTEXT_CLUES}; use crate::app::radarr::radarr_context_clues::SYSTEM_TASKS_CONTEXT_CLUES; use crate::app::App; -use crate::models::radarr_models::Task; +use crate::models::radarr_models::RadarrTask; use crate::models::servarr_data::radarr::radarr_data::{ActiveRadarrBlock, SYSTEM_DETAILS_BLOCKS}; use crate::models::Route; use crate::ui::radarr_ui::radarr_ui_utils::style_log_list_item; @@ -108,7 +108,7 @@ fn draw_logs_popup(f: &mut Frame<'_>, app: &mut App<'_>) { fn draw_tasks_popup(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { let help_footer = Some(build_context_clue_string(&SYSTEM_TASKS_CONTEXT_CLUES)); - let tasks_row_mapping = |task: &Task| { + let tasks_row_mapping = |task: &RadarrTask| { let task_props = extract_task_props(task); Row::new(vec![ From 22fbe025d956a68aaab73c813d4aeba8d8e83e98 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Fri, 22 Nov 2024 16:37:21 -0700 Subject: [PATCH 063/119] feat(cli): Support for listing all Sonarr tasks --- src/cli/radarr/list_command_handler.rs | 2 +- src/cli/sonarr/list_command_handler.rs | 9 +++++++++ src/cli/sonarr/list_command_handler_tests.rs | 4 +++- 3 files changed, 13 insertions(+), 2 deletions(-) diff --git a/src/cli/radarr/list_command_handler.rs b/src/cli/radarr/list_command_handler.rs index 7d0962e..b769033 100644 --- a/src/cli/radarr/list_command_handler.rs +++ b/src/cli/radarr/list_command_handler.rs @@ -57,7 +57,7 @@ pub enum RadarrListCommand { RootFolders, #[command(about = "List all Radarr tags")] Tags, - #[command(about = "List tasks")] + #[command(about = "List all Radarr tasks")] Tasks, #[command(about = "List all Radarr updates")] Updates, diff --git a/src/cli/sonarr/list_command_handler.rs b/src/cli/sonarr/list_command_handler.rs index ff35417..ba85fae 100644 --- a/src/cli/sonarr/list_command_handler.rs +++ b/src/cli/sonarr/list_command_handler.rs @@ -78,6 +78,8 @@ pub enum SonarrListCommand { }, #[command(about = "List all Sonarr tags")] Tags, + #[command(about = "List all Sonarr tasks")] + Tasks, } impl From for Command { @@ -215,6 +217,13 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, SonarrListCommand> for SonarrListCommandH .await?; serde_json::to_string_pretty(&resp)? } + SonarrListCommand::Tasks => { + let resp = self + .network + .handle_network_event(SonarrEvent::GetTasks.into()) + .await?; + serde_json::to_string_pretty(&resp)? + } }; Ok(result) diff --git a/src/cli/sonarr/list_command_handler_tests.rs b/src/cli/sonarr/list_command_handler_tests.rs index 9d4bab5..21ac81d 100644 --- a/src/cli/sonarr/list_command_handler_tests.rs +++ b/src/cli/sonarr/list_command_handler_tests.rs @@ -33,7 +33,8 @@ mod tests { "indexers", "queued-events", "root-folders", - "tags" + "tags", + "tasks" )] subcommand: &str, ) { @@ -206,6 +207,7 @@ mod tests { #[case(SonarrListCommand::RootFolders, SonarrEvent::GetRootFolders)] #[case(SonarrListCommand::Series, SonarrEvent::ListSeries)] #[case(SonarrListCommand::Tags, SonarrEvent::GetTags)] + #[case(SonarrListCommand::Tasks, SonarrEvent::GetTasks)] #[tokio::test] async fn test_handle_list_command( #[case] list_command: SonarrListCommand, From 16ca8841a1b35219f04f6630c780f96e80abe727 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Fri, 22 Nov 2024 16:46:36 -0700 Subject: [PATCH 064/119] feat(network): Support for fetching Sonarr updates --- src/models/radarr_models.rs | 20 +--- src/models/servarr_data/sonarr/sonarr_data.rs | 4 +- .../servarr_data/sonarr/sonarr_data_tests.rs | 1 + src/models/servarr_models.rs | 18 ++++ src/models/sonarr_models.rs | 16 +-- src/models/sonarr_models_tests.rs | 14 ++- src/network/radarr_network.rs | 8 +- src/network/radarr_network_tests.rs | 2 +- src/network/sonarr_network.rs | 83 ++++++++++++++- src/network/sonarr_network_tests.rs | 100 +++++++++++++++++- 10 files changed, 230 insertions(+), 36 deletions(-) diff --git a/src/models/radarr_models.rs b/src/models/radarr_models.rs index ecb7b16..28fa649 100644 --- a/src/models/radarr_models.rs +++ b/src/models/radarr_models.rs @@ -11,7 +11,7 @@ use crate::{models::HorizontallyScrollableText, serde_enum_from}; use super::servarr_models::{ DiskSpace, HostConfig, Indexer, Language, LogResponse, QualityProfile, QualityWrapper, - QueueEvent, Release, RootFolder, SecurityConfig, Tag, + QueueEvent, Release, RootFolder, SecurityConfig, Tag, Update, }; use super::{EnumDisplayStyle, Serdeable}; @@ -471,24 +471,6 @@ impl Display for RadarrTaskName { } } -#[derive(Default, Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct Update { - pub version: String, - pub release_date: DateTime, - pub installed: bool, - pub latest: bool, - pub installed_on: Option>, - pub changes: UpdateChanges, -} - -#[derive(Default, Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct UpdateChanges { - pub new: Option>, - pub fixed: Option>, -} - #[derive(Clone, Serialize, Deserialize, Debug, PartialEq, Eq)] #[serde(untagged)] #[allow(clippy::large_enum_variant)] diff --git a/src/models/servarr_data/sonarr/sonarr_data.rs b/src/models/servarr_data/sonarr/sonarr_data.rs index db5461f..f497a86 100644 --- a/src/models/servarr_data/sonarr/sonarr_data.rs +++ b/src/models/servarr_data/sonarr/sonarr_data.rs @@ -9,7 +9,7 @@ use crate::models::{ }, stateful_list::StatefulList, stateful_table::StatefulTable, - HorizontallyScrollableText, Route, + HorizontallyScrollableText, Route, ScrollableText, }; use super::modals::SeasonDetailsModal; @@ -37,6 +37,7 @@ pub struct SonarrData { pub start_time: DateTime, pub tags_map: BiMap, pub tasks: StatefulTable, + pub updates: ScrollableText, pub version: String, } @@ -61,6 +62,7 @@ impl Default for SonarrData { start_time: DateTime::default(), tags_map: BiMap::default(), tasks: StatefulTable::default(), + updates: ScrollableText::default(), version: String::new(), } } diff --git a/src/models/servarr_data/sonarr/sonarr_data_tests.rs b/src/models/servarr_data/sonarr/sonarr_data_tests.rs index 495a5af..ca44f74 100644 --- a/src/models/servarr_data/sonarr/sonarr_data_tests.rs +++ b/src/models/servarr_data/sonarr/sonarr_data_tests.rs @@ -52,6 +52,7 @@ mod tests { assert_eq!(sonarr_data.start_time, >::default()); assert!(sonarr_data.tags_map.is_empty()); assert!(sonarr_data.tasks.is_empty()); + assert!(sonarr_data.updates.is_empty()); assert!(sonarr_data.version.is_empty()); } } diff --git a/src/models/servarr_models.rs b/src/models/servarr_models.rs index 82cdc9e..9c95dcb 100644 --- a/src/models/servarr_models.rs +++ b/src/models/servarr_models.rs @@ -249,3 +249,21 @@ pub struct UnmappedFolder { pub name: String, pub path: String, } + +#[derive(Default, Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct Update { + pub version: String, + pub release_date: DateTime, + pub installed: bool, + pub latest: bool, + pub installed_on: Option>, + pub changes: UpdateChanges, +} + +#[derive(Default, Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct UpdateChanges { + pub new: Option>, + pub fixed: Option>, +} diff --git a/src/models/sonarr_models.rs b/src/models/sonarr_models.rs index e5ad2a6..a891eaa 100644 --- a/src/models/sonarr_models.rs +++ b/src/models/sonarr_models.rs @@ -12,7 +12,7 @@ use crate::serde_enum_from; use super::{ servarr_models::{ DiskSpace, HostConfig, Indexer, Language, LogResponse, QualityProfile, QualityWrapper, - QueueEvent, Release, RootFolder, SecurityConfig, Tag, + QueueEvent, Release, RootFolder, SecurityConfig, Tag, Update, }, EnumDisplayStyle, HorizontallyScrollableText, Serdeable, }; @@ -430,7 +430,7 @@ impl Display for SonarrTaskName { #[serde(untagged)] #[allow(clippy::large_enum_variant)] pub enum SonarrSerdeable { - Value(Value), + BlocklistResponse(BlocklistResponse), DownloadsResponse(DownloadsResponse), DiskSpaces(Vec), Episode(Episode), @@ -438,6 +438,7 @@ pub enum SonarrSerdeable { HostConfig(HostConfig), IndexerSettings(IndexerSettings), Indexers(Vec), + LogResponse(LogResponse), QualityProfiles(Vec), QueueEvents(Vec), Releases(Vec), @@ -451,8 +452,8 @@ pub enum SonarrSerdeable { Tag(Tag), Tags(Vec), Tasks(Vec), - BlocklistResponse(BlocklistResponse), - LogResponse(LogResponse), + Updates(Vec), + Value(Value), } impl From for Serdeable { @@ -469,7 +470,7 @@ impl From<()> for SonarrSerdeable { serde_enum_from!( SonarrSerdeable { - Value(Value), + BlocklistResponse(BlocklistResponse), DownloadsResponse(DownloadsResponse), DiskSpaces(Vec), Episode(Episode), @@ -477,6 +478,7 @@ serde_enum_from!( HostConfig(HostConfig), IndexerSettings(IndexerSettings), Indexers(Vec), + LogResponse(LogResponse), QualityProfiles(Vec), QueueEvents(Vec), Releases(Vec), @@ -490,8 +492,8 @@ serde_enum_from!( Tag(Tag), Tags(Vec), Tasks(Vec), - BlocklistResponse(BlocklistResponse), - LogResponse(LogResponse), + Updates(Vec), + Value(Value), } ); diff --git a/src/models/sonarr_models_tests.rs b/src/models/sonarr_models_tests.rs index abf970a..1893ac6 100644 --- a/src/models/sonarr_models_tests.rs +++ b/src/models/sonarr_models_tests.rs @@ -6,7 +6,7 @@ mod tests { use crate::models::{ servarr_models::{ DiskSpace, HostConfig, Indexer, Log, LogResponse, QualityProfile, QueueEvent, Release, - RootFolder, SecurityConfig, Tag, + RootFolder, SecurityConfig, Tag, Update, }, sonarr_models::{ BlocklistItem, BlocklistResponse, DownloadRecord, DownloadsResponse, Episode, @@ -426,4 +426,16 @@ mod tests { assert_eq!(sonarr_serdeable, SonarrSerdeable::Tasks(tasks)); } + + #[test] + fn test_sonarr_serdeable_from_updates() { + let updates = vec![Update { + version: "test".to_owned(), + ..Update::default() + }]; + + let sonarr_serdeable: SonarrSerdeable = updates.clone().into(); + + assert_eq!(sonarr_serdeable, SonarrSerdeable::Updates(updates)); + } } diff --git a/src/network/radarr_network.rs b/src/network/radarr_network.rs index 88bf9ea..1bf6b7e 100644 --- a/src/network/radarr_network.rs +++ b/src/network/radarr_network.rs @@ -11,7 +11,7 @@ use crate::models::radarr_models::{ CommandBody, Credit, CreditType, DeleteMovieParams, DownloadRecord, DownloadsResponse, EditCollectionParams, EditIndexerParams, EditMovieParams, IndexerSettings, IndexerTestResult, Movie, MovieCommandBody, MovieHistoryItem, RadarrSerdeable, RadarrTask, RadarrTaskName, - ReleaseDownloadBody, SystemStatus, Update, + ReleaseDownloadBody, SystemStatus, }; use crate::models::servarr_data::radarr::modals::{ AddMovieModal, EditCollectionModal, EditIndexerModal, EditMovieModal, IndexerTestResultModalItem, @@ -20,7 +20,7 @@ use crate::models::servarr_data::radarr::modals::{ use crate::models::servarr_data::radarr::radarr_data::ActiveRadarrBlock; use crate::models::servarr_models::{ AddRootFolderBody, DiskSpace, HostConfig, Indexer, LogResponse, QualityProfile, QueueEvent, - Release, RootFolder, SecurityConfig, Tag, + Release, RootFolder, SecurityConfig, Tag, Update, }; use crate::models::stateful_table::StatefulTable; use crate::models::{HorizontallyScrollableText, Route, Scrollable, ScrollableText}; @@ -244,7 +244,7 @@ impl<'a, 'b> Network<'a, 'b> { RadarrEvent::GetStatus => self.get_radarr_status().await.map(RadarrSerdeable::from), RadarrEvent::GetTags => self.get_radarr_tags().await.map(RadarrSerdeable::from), RadarrEvent::GetTasks => self.get_radarr_tasks().await.map(RadarrSerdeable::from), - RadarrEvent::GetUpdates => self.get_updates().await.map(RadarrSerdeable::from), + RadarrEvent::GetUpdates => self.get_radarr_updates().await.map(RadarrSerdeable::from), RadarrEvent::HealthCheck => self .get_radarr_healthcheck() .await @@ -1870,7 +1870,7 @@ impl<'a, 'b> Network<'a, 'b> { .await } - async fn get_updates(&mut self) -> Result> { + async fn get_radarr_updates(&mut self) -> Result> { info!("Fetching Radarr updates"); let event = RadarrEvent::GetUpdates; diff --git a/src/network/radarr_network_tests.rs b/src/network/radarr_network_tests.rs index c4cc9f6..1f0dc5f 100644 --- a/src/network/radarr_network_tests.rs +++ b/src/network/radarr_network_tests.rs @@ -2730,7 +2730,7 @@ mod test { } #[tokio::test] - async fn test_handle_get_updates_event() { + async fn test_handle_get_radarr_updates_event() { let updates_json = json!([{ "version": "4.3.2.1", "releaseDate": "2023-04-15T02:02:53Z", diff --git a/src/network/sonarr_network.rs b/src/network/sonarr_network.rs index f8029ff..adb9f8e 100644 --- a/src/network/sonarr_network.rs +++ b/src/network/sonarr_network.rs @@ -11,7 +11,7 @@ use crate::{ }, servarr_models::{ AddRootFolderBody, DiskSpace, HostConfig, Indexer, LogResponse, QualityProfile, QueueEvent, - Release, RootFolder, SecurityConfig, Tag, + Release, RootFolder, SecurityConfig, Tag, Update, }, sonarr_models::{ BlocklistResponse, DownloadRecord, DownloadsResponse, Episode, IndexerSettings, Series, @@ -59,6 +59,7 @@ pub enum SonarrEvent { GetSeriesDetails(Option), GetSeriesHistory(Option), GetStatus, + GetUpdates, GetTags, GetTasks, HealthCheck, @@ -90,6 +91,7 @@ impl NetworkResource for SonarrEvent { SonarrEvent::GetSeriesHistory(_) => "/history/series", SonarrEvent::GetStatus => "/system/status", SonarrEvent::GetTasks => "/system/task", + SonarrEvent::GetUpdates => "/update", SonarrEvent::HealthCheck => "/health", SonarrEvent::ListSeries | SonarrEvent::GetSeriesDetails(_) => "/series", SonarrEvent::MarkHistoryItemAsFailed(_) => "/history/failed", @@ -205,6 +207,7 @@ impl<'a, 'b> Network<'a, 'b> { SonarrEvent::GetStatus => self.get_sonarr_status().await.map(SonarrSerdeable::from), SonarrEvent::GetTags => self.get_sonarr_tags().await.map(SonarrSerdeable::from), SonarrEvent::GetTasks => self.get_sonarr_tasks().await.map(SonarrSerdeable::from), + SonarrEvent::GetUpdates => self.get_sonarr_updates().await.map(SonarrSerdeable::from), SonarrEvent::HealthCheck => self .get_sonarr_healthcheck() .await @@ -1174,6 +1177,84 @@ impl<'a, 'b> Network<'a, 'b> { .await } + async fn get_sonarr_updates(&mut self) -> Result> { + info!("Fetching Sonarr updates"); + let event = SonarrEvent::GetUpdates; + + let request_props = self + .request_props_from(event, RequestMethod::Get, None::<()>, None, None) + .await; + + self + .handle_request::<(), Vec>(request_props, |updates_vec, mut app| { + let latest_installed = if updates_vec + .iter() + .any(|update| update.latest && update.installed_on.is_some()) + { + "already".to_owned() + } else { + "not".to_owned() + }; + let updates = updates_vec + .into_iter() + .map(|update| { + let install_status = if update.installed_on.is_some() { + if update.installed { + "(Currently Installed)".to_owned() + } else { + "(Previously Installed)".to_owned() + } + } else { + String::new() + }; + let vec_to_bullet_points = |vec: Vec| { + vec + .iter() + .map(|change| format!(" * {change}")) + .collect::>() + .join("\n") + }; + + let mut update_info = formatdoc!( + "{} - {} {install_status} + {}", + update.version, + update.release_date, + "-".repeat(200) + ); + + if let Some(new_changes) = update.changes.new { + let changes = vec_to_bullet_points(new_changes); + update_info = formatdoc!( + "{update_info} + New: + {changes}" + ) + } + + if let Some(fixes) = update.changes.fixed { + let fixes = vec_to_bullet_points(fixes); + update_info = formatdoc!( + "{update_info} + Fixed: + {fixes}" + ); + } + + update_info + }) + .reduce(|version_1, version_2| format!("{version_1}\n\n\n{version_2}")) + .unwrap(); + + app.data.sonarr_data.updates = ScrollableText::with_string(formatdoc!( + "The latest version of Sonarr is {latest_installed} installed + + {updates}" + )); + }) + .await + } + async fn mark_sonarr_history_item_as_failed(&mut self, history_item_id: i64) -> Result { info!("Marking the Sonarr history item with ID: {history_item_id} as 'failed'"); let event = SonarrEvent::MarkHistoryItemAsFailed(history_item_id); diff --git a/src/network/sonarr_network_tests.rs b/src/network/sonarr_network_tests.rs index 9eddb75..9834011 100644 --- a/src/network/sonarr_network_tests.rs +++ b/src/network/sonarr_network_tests.rs @@ -18,7 +18,7 @@ mod test { use crate::models::servarr_data::sonarr::sonarr_data::ActiveSonarrBlock; use crate::models::servarr_models::{ DiskSpace, HostConfig, Indexer, IndexerField, Language, LogResponse, Quality, QualityProfile, - QualityWrapper, QueueEvent, Release, RootFolder, SecurityConfig, Tag, + QualityWrapper, QueueEvent, Release, RootFolder, SecurityConfig, Tag, Update, }; use crate::models::sonarr_models::{ BlocklistItem, DownloadRecord, DownloadsResponse, Episode, EpisodeFile, MediaInfo, @@ -29,8 +29,8 @@ mod test { }; use crate::models::sonarr_models::{SonarrTask, SystemStatus}; use crate::models::stateful_table::StatefulTable; - use crate::models::HorizontallyScrollableText; use crate::models::{sonarr_models::SonarrSerdeable, stateful_table::SortOption}; + use crate::models::{HorizontallyScrollableText, ScrollableText}; use crate::network::sonarr_network::get_episode_status; use crate::{ @@ -219,6 +219,7 @@ mod test { #[case(SonarrEvent::GetQualityProfiles, "/qualityprofile")] #[case(SonarrEvent::GetStatus, "/system/status")] #[case(SonarrEvent::GetTasks, "/system/task")] + #[case(SonarrEvent::GetUpdates, "/update")] #[case(SonarrEvent::MarkHistoryItemAsFailed(0), "/history/failed")] fn test_resource(#[case] event: SonarrEvent, #[case] expected_uri: String) { assert_str_eq!(event.resource(), expected_uri); @@ -3741,6 +3742,101 @@ mod test { } } + #[tokio::test] + async fn test_handle_get_sonarr_updates_event() { + let updates_json = json!([{ + "version": "4.3.2.1", + "releaseDate": "2023-04-15T02:02:53Z", + "installed": true, + "installedOn": "2023-04-15T02:02:53Z", + "latest": true, + "changes": { + "new": [ + "Cool new thing" + ], + "fixed": [ + "Some bugs killed" + ] + }, + }, + { + "version": "3.2.1.0", + "releaseDate": "2023-04-15T02:02:53Z", + "installed": false, + "installedOn": "2023-04-15T02:02:53Z", + "latest": false, + "changes": { + "new": [ + "Cool new thing (old)", + "Other cool new thing (old)" + ], + }, + }, + { + "version": "2.1.0", + "releaseDate": "2023-04-15T02:02:53Z", + "installed": false, + "latest": false, + "changes": { + "fixed": [ + "Killed bug 1", + "Fixed bug 2" + ] + }, + }]); + let response: Vec = serde_json::from_value(updates_json.clone()).unwrap(); + let line_break = "-".repeat(200); + let expected_text = ScrollableText::with_string(formatdoc!( + " + The latest version of Sonarr is already installed + + 4.3.2.1 - 2023-04-15 02:02:53 UTC (Currently Installed) + {line_break} + New: + * Cool new thing + Fixed: + * Some bugs killed + + + 3.2.1.0 - 2023-04-15 02:02:53 UTC (Previously Installed) + {line_break} + New: + * Cool new thing (old) + * Other cool new thing (old) + + + 2.1.0 - 2023-04-15 02:02:53 UTC + {line_break} + Fixed: + * Killed bug 1 + * Fixed bug 2" + )); + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(updates_json), + None, + SonarrEvent::GetUpdates, + None, + None, + ) + .await; + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + if let SonarrSerdeable::Updates(updates) = network + .handle_sonarr_event(SonarrEvent::GetUpdates) + .await + .unwrap() + { + async_server.assert_async().await; + assert_str_eq!( + app_arc.lock().await.data.sonarr_data.updates.get_text(), + expected_text.get_text() + ); + assert_eq!(updates, response); + } + } + #[tokio::test] async fn test_handle_mark_sonarr_history_item_as_failed_event() { let expected_history_item_id = 1; From 8df74585bc66f0d5771b0f93acb76d1d28ad0a6b Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Fri, 22 Nov 2024 16:48:09 -0700 Subject: [PATCH 065/119] feat(cli): Support for listing Sonarr updates --- src/cli/sonarr/list_command_handler.rs | 9 +++++++++ src/cli/sonarr/list_command_handler_tests.rs | 4 +++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/src/cli/sonarr/list_command_handler.rs b/src/cli/sonarr/list_command_handler.rs index ba85fae..c491b26 100644 --- a/src/cli/sonarr/list_command_handler.rs +++ b/src/cli/sonarr/list_command_handler.rs @@ -80,6 +80,8 @@ pub enum SonarrListCommand { Tags, #[command(about = "List all Sonarr tasks")] Tasks, + #[command(about = "List all Sonarr updates")] + Updates, } impl From for Command { @@ -224,6 +226,13 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, SonarrListCommand> for SonarrListCommandH .await?; serde_json::to_string_pretty(&resp)? } + SonarrListCommand::Updates => { + let resp = self + .network + .handle_network_event(SonarrEvent::GetUpdates.into()) + .await?; + serde_json::to_string_pretty(&resp)? + } }; Ok(result) diff --git a/src/cli/sonarr/list_command_handler_tests.rs b/src/cli/sonarr/list_command_handler_tests.rs index 21ac81d..886750e 100644 --- a/src/cli/sonarr/list_command_handler_tests.rs +++ b/src/cli/sonarr/list_command_handler_tests.rs @@ -34,7 +34,8 @@ mod tests { "queued-events", "root-folders", "tags", - "tasks" + "tasks", + "updates" )] subcommand: &str, ) { @@ -208,6 +209,7 @@ mod tests { #[case(SonarrListCommand::Series, SonarrEvent::ListSeries)] #[case(SonarrListCommand::Tags, SonarrEvent::GetTags)] #[case(SonarrListCommand::Tasks, SonarrEvent::GetTasks)] + #[case(SonarrListCommand::Updates, SonarrEvent::GetUpdates)] #[tokio::test] async fn test_handle_list_command( #[case] list_command: SonarrListCommand, From 33db3efacfb977c7269b9163247f96f5c1c4ee84 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Fri, 22 Nov 2024 16:57:09 -0700 Subject: [PATCH 066/119] feat(network): Support for starting a Sonarr task --- src/models/radarr_models.rs | 6 --- src/models/servarr_models.rs | 6 +++ src/network/radarr_network.rs | 20 ++++----- src/network/radarr_network_tests.rs | 4 +- src/network/sonarr_network.rs | 43 ++++++++++++++++-- src/network/sonarr_network_tests.rs | 68 ++++++++++++++++++++++++++++- 6 files changed, 125 insertions(+), 22 deletions(-) diff --git a/src/models/radarr_models.rs b/src/models/radarr_models.rs index 28fa649..080ae3f 100644 --- a/src/models/radarr_models.rs +++ b/src/models/radarr_models.rs @@ -116,12 +116,6 @@ pub struct CollectionMovie { pub ratings: RatingsList, } -#[derive(Default, Serialize, Debug)] -#[serde(rename_all = "camelCase")] -pub struct CommandBody { - pub name: String, -} - #[derive(Serialize, Deserialize, Default, Clone, Debug, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct Credit { diff --git a/src/models/servarr_models.rs b/src/models/servarr_models.rs index 9c95dcb..3c2b5c1 100644 --- a/src/models/servarr_models.rs +++ b/src/models/servarr_models.rs @@ -74,6 +74,12 @@ impl Display for CertificateValidation { } } +#[derive(Default, Serialize, Debug)] +#[serde(rename_all = "camelCase")] +pub struct CommandBody { + pub name: String, +} + #[derive(Serialize, Deserialize, Debug, Clone, Eq, PartialEq)] #[serde(rename_all = "camelCase")] pub struct DiskSpace { diff --git a/src/network/radarr_network.rs b/src/network/radarr_network.rs index 1bf6b7e..e2c629b 100644 --- a/src/network/radarr_network.rs +++ b/src/network/radarr_network.rs @@ -8,10 +8,9 @@ use urlencoding::encode; use crate::models::radarr_models::{ AddMovieBody, AddMovieSearchResult, AddOptions, BlocklistResponse, Collection, CollectionMovie, - CommandBody, Credit, CreditType, DeleteMovieParams, DownloadRecord, DownloadsResponse, - EditCollectionParams, EditIndexerParams, EditMovieParams, IndexerSettings, IndexerTestResult, - Movie, MovieCommandBody, MovieHistoryItem, RadarrSerdeable, RadarrTask, RadarrTaskName, - ReleaseDownloadBody, SystemStatus, + Credit, CreditType, DeleteMovieParams, DownloadRecord, DownloadsResponse, EditCollectionParams, + EditIndexerParams, EditMovieParams, IndexerSettings, IndexerTestResult, Movie, MovieCommandBody, + MovieHistoryItem, RadarrSerdeable, RadarrTask, RadarrTaskName, ReleaseDownloadBody, SystemStatus, }; use crate::models::servarr_data::radarr::modals::{ AddMovieModal, EditCollectionModal, EditIndexerModal, EditMovieModal, IndexerTestResultModalItem, @@ -19,8 +18,8 @@ use crate::models::servarr_data::radarr::modals::{ }; use crate::models::servarr_data::radarr::radarr_data::ActiveRadarrBlock; use crate::models::servarr_models::{ - AddRootFolderBody, DiskSpace, HostConfig, Indexer, LogResponse, QualityProfile, QueueEvent, - Release, RootFolder, SecurityConfig, Tag, Update, + AddRootFolderBody, CommandBody, DiskSpace, HostConfig, Indexer, LogResponse, QualityProfile, + QueueEvent, Release, RootFolder, SecurityConfig, Tag, Update, }; use crate::models::stateful_table::StatefulTable; use crate::models::{HorizontallyScrollableText, Route, Scrollable, ScrollableText}; @@ -252,9 +251,10 @@ impl<'a, 'b> Network<'a, 'b> { RadarrEvent::SearchNewMovie(query) => { self.search_movie(query).await.map(RadarrSerdeable::from) } - RadarrEvent::StartTask(task_name) => { - self.start_task(task_name).await.map(RadarrSerdeable::from) - } + RadarrEvent::StartTask(task_name) => self + .start_radarr_task(task_name) + .await + .map(RadarrSerdeable::from), RadarrEvent::TestIndexer(indexer_id) => self .test_indexer(indexer_id) .await @@ -2006,7 +2006,7 @@ impl<'a, 'b> Network<'a, 'b> { } } - async fn start_task(&mut self, task: Option) -> Result { + async fn start_radarr_task(&mut self, task: Option) -> Result { let event = RadarrEvent::StartTask(None); let task_name = if let Some(t_name) = task { t_name diff --git a/src/network/radarr_network_tests.rs b/src/network/radarr_network_tests.rs index 1f0dc5f..edda118 100644 --- a/src/network/radarr_network_tests.rs +++ b/src/network/radarr_network_tests.rs @@ -713,7 +713,7 @@ mod test { } #[tokio::test] - async fn test_handle_start_task_event() { + async fn test_handle_start_radarr_task_event() { let response = json!({ "test": "test"}); let (async_server, app_arc, _server) = mock_servarr_api( RequestMethod::Post, @@ -750,7 +750,7 @@ mod test { } #[tokio::test] - async fn test_handle_start_task_event_uses_provided_task_name() { + async fn test_handle_start_radarr_task_event_uses_provided_task_name() { let response = json!({ "test": "test"}); let (async_server, app_arc, _server) = mock_servarr_api( RequestMethod::Post, diff --git a/src/network/sonarr_network.rs b/src/network/sonarr_network.rs index adb9f8e..9aa4b62 100644 --- a/src/network/sonarr_network.rs +++ b/src/network/sonarr_network.rs @@ -10,12 +10,13 @@ use crate::{ sonarr_data::ActiveSonarrBlock, }, servarr_models::{ - AddRootFolderBody, DiskSpace, HostConfig, Indexer, LogResponse, QualityProfile, QueueEvent, - Release, RootFolder, SecurityConfig, Tag, Update, + AddRootFolderBody, CommandBody, DiskSpace, HostConfig, Indexer, LogResponse, QualityProfile, + QueueEvent, Release, RootFolder, SecurityConfig, Tag, Update, }, sonarr_models::{ BlocklistResponse, DownloadRecord, DownloadsResponse, Episode, IndexerSettings, Series, - SonarrHistoryItem, SonarrHistoryWrapper, SonarrSerdeable, SonarrTask, SystemStatus, + SonarrHistoryItem, SonarrHistoryWrapper, SonarrSerdeable, SonarrTask, SonarrTaskName, + SystemStatus, }, stateful_table::StatefulTable, HorizontallyScrollableText, Route, Scrollable, ScrollableText, @@ -65,6 +66,7 @@ pub enum SonarrEvent { HealthCheck, ListSeries, MarkHistoryItemAsFailed(i64), + StartTask(Option), } impl NetworkResource for SonarrEvent { @@ -95,6 +97,7 @@ impl NetworkResource for SonarrEvent { SonarrEvent::HealthCheck => "/health", SonarrEvent::ListSeries | SonarrEvent::GetSeriesDetails(_) => "/series", SonarrEvent::MarkHistoryItemAsFailed(_) => "/history/failed", + SonarrEvent::StartTask(_) => "/command", } } } @@ -217,6 +220,10 @@ impl<'a, 'b> Network<'a, 'b> { .mark_sonarr_history_item_as_failed(history_item_id) .await .map(SonarrSerdeable::from), + SonarrEvent::StartTask(task_name) => self + .start_sonarr_task(task_name) + .await + .map(SonarrSerdeable::from), } } @@ -1274,6 +1281,36 @@ impl<'a, 'b> Network<'a, 'b> { .await } + async fn start_sonarr_task(&mut self, task: Option) -> Result { + let event = SonarrEvent::StartTask(None); + let task_name = if let Some(t_name) = task { + t_name + } else { + self + .app + .lock() + .await + .data + .sonarr_data + .tasks + .current_selection() + .task_name + } + .to_string(); + + info!("Starting Sonarr task: {task_name}"); + + let body = CommandBody { name: task_name }; + + let request_props = self + .request_props_from(event, RequestMethod::Post, Some(body), None, None) + .await; + + self + .handle_request::(request_props, |_, _| ()) + .await + } + async fn extract_series_id(&mut self, series_id: Option) -> (i64, String) { let series_id = if let Some(id) = series_id { id diff --git a/src/network/sonarr_network_tests.rs b/src/network/sonarr_network_tests.rs index 9834011..39dfccf 100644 --- a/src/network/sonarr_network_tests.rs +++ b/src/network/sonarr_network_tests.rs @@ -159,7 +159,9 @@ mod test { } #[rstest] - fn test_resource_command(#[values(SonarrEvent::GetQueuedEvents)] event: SonarrEvent) { + fn test_resource_command( + #[values(SonarrEvent::GetQueuedEvents, SonarrEvent::StartTask(None))] event: SonarrEvent, + ) { assert_str_eq!(event.resource(), "/command"); } @@ -3861,6 +3863,70 @@ mod test { async_server.assert_async().await; } + #[tokio::test] + async fn test_handle_start_sonarr_task_event() { + let response = json!({ "test": "test"}); + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Post, + Some(json!({ + "name": "ApplicationUpdateCheck" + })), + Some(response.clone()), + None, + SonarrEvent::StartTask(None), + None, + None, + ) + .await; + app_arc + .lock() + .await + .data + .sonarr_data + .tasks + .set_items(vec![SonarrTask { + task_name: SonarrTaskName::default(), + ..SonarrTask::default() + }]); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + if let SonarrSerdeable::Value(value) = network + .handle_sonarr_event(SonarrEvent::StartTask(None)) + .await + .unwrap() + { + async_server.assert_async().await; + assert_eq!(value, response); + } + } + + #[tokio::test] + async fn test_handle_start_sonarr_task_event_uses_provided_task_name() { + let response = json!({ "test": "test"}); + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Post, + Some(json!({ + "name": "ApplicationUpdateCheck" + })), + Some(response.clone()), + None, + SonarrEvent::StartTask(None), + None, + None, + ) + .await; + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + if let SonarrSerdeable::Value(value) = network + .handle_sonarr_event(SonarrEvent::StartTask(Some(SonarrTaskName::default()))) + .await + .unwrap() + { + async_server.assert_async().await; + assert_eq!(value, response); + } + } + #[tokio::test] async fn test_extract_series_id() { let app_arc = Arc::new(Mutex::new(App::default())); From aa13735533a6ae0d0a1297c536dad12d79c88737 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Fri, 22 Nov 2024 17:01:00 -0700 Subject: [PATCH 067/119] feat(cli): Support for starting a Sonarr task --- src/cli/sonarr/mod.rs | 18 +++++++ src/cli/sonarr/sonarr_command_tests.rs | 69 +++++++++++++++++++++++++- 2 files changed, 86 insertions(+), 1 deletion(-) diff --git a/src/cli/sonarr/mod.rs b/src/cli/sonarr/mod.rs index 658c56e..870223e 100644 --- a/src/cli/sonarr/mod.rs +++ b/src/cli/sonarr/mod.rs @@ -10,6 +10,7 @@ use tokio::sync::Mutex; use crate::{ app::App, + models::sonarr_models::SonarrTaskName, network::{sonarr_network::SonarrEvent, NetworkTrait}, }; @@ -79,6 +80,16 @@ pub enum SonarrCommand { #[arg(long, help = "The season number to search for", required = true)] season_number: i64, }, + #[command(about = "Start the specified Sonarr task")] + StartTask { + #[arg( + long, + help = "The name of the task to trigger", + value_enum, + required = true + )] + task_name: SonarrTaskName, + }, } impl From for Command { @@ -167,6 +178,13 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, SonarrCommand> for SonarrCliHandler<'a, ' .await?; serde_json::to_string_pretty(&resp)? } + SonarrCommand::StartTask { task_name } => { + let resp = self + .network + .handle_network_event(SonarrEvent::StartTask(Some(task_name)).into()) + .await?; + serde_json::to_string_pretty(&resp)? + } }; Ok(result) diff --git a/src/cli/sonarr/sonarr_command_tests.rs b/src/cli/sonarr/sonarr_command_tests.rs index be2f4b5..fc0988e 100644 --- a/src/cli/sonarr/sonarr_command_tests.rs +++ b/src/cli/sonarr/sonarr_command_tests.rs @@ -126,6 +126,44 @@ mod tests { assert!(result.is_ok()); } + + #[rstest] + fn test_start_task_requires_task_name() { + let result = Cli::command().try_get_matches_from(["managarr", "sonarr", "start-task"]); + + assert!(result.is_err()); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[rstest] + fn test_start_task_task_name_validation() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "sonarr", + "start-task", + "--task-name", + "test", + ]); + + assert!(result.is_err()); + assert_eq!(result.unwrap_err().kind(), ErrorKind::InvalidValue); + } + + #[test] + fn test_start_task_requirements_satisfied() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "sonarr", + "start-task", + "--task-name", + "application-update-check", + ]); + + assert!(result.is_ok()); + } } mod handler { @@ -146,7 +184,9 @@ mod tests { CliCommandHandler, }, models::{ - sonarr_models::{BlocklistItem, BlocklistResponse, Series, SonarrSerdeable}, + sonarr_models::{ + BlocklistItem, BlocklistResponse, Series, SonarrSerdeable, SonarrTaskName, + }, Serdeable, }, network::{sonarr_network::SonarrEvent, MockNetworkTrait, NetworkEvent}, @@ -370,5 +410,32 @@ mod tests { assert!(result.is_ok()); } + + #[tokio::test] + async fn test_start_task_command() { + let expected_task_name = SonarrTaskName::ApplicationUpdateCheck; + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::( + SonarrEvent::StartTask(Some(expected_task_name)).into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Sonarr(SonarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::default())); + let start_task_command = SonarrCommand::StartTask { + task_name: SonarrTaskName::ApplicationUpdateCheck, + }; + + let result = SonarrCliHandler::with(&app_arc, start_task_command, &mut mock_network) + .handle() + .await; + + assert!(result.is_ok()); + } } } From 9403bdcbcbd47d4f7ab68f0d6329cbf1ab81de7f Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Fri, 22 Nov 2024 17:10:06 -0700 Subject: [PATCH 068/119] fix(network): Not all Sonarr tasks return the lastDuration field and was causing a crash --- src/models/sonarr_models.rs | 1 - src/network/sonarr_network_tests.rs | 4 ---- 2 files changed, 5 deletions(-) diff --git a/src/models/sonarr_models.rs b/src/models/sonarr_models.rs index a891eaa..8973709 100644 --- a/src/models/sonarr_models.rs +++ b/src/models/sonarr_models.rs @@ -396,7 +396,6 @@ pub struct SonarrTask { #[serde(deserialize_with = "super::from_i64")] pub interval: i64, pub last_execution: DateTime, - pub last_duration: String, pub next_execution: DateTime, } diff --git a/src/network/sonarr_network_tests.rs b/src/network/sonarr_network_tests.rs index 39dfccf..d247564 100644 --- a/src/network/sonarr_network_tests.rs +++ b/src/network/sonarr_network_tests.rs @@ -3688,7 +3688,6 @@ mod test { "interval": 360, "lastExecution": "2023-05-20T21:29:16Z", "nextExecution": "2023-05-20T21:29:16Z", - "lastDuration": "00:00:00.5111547", }, { "name": "Backup", @@ -3696,7 +3695,6 @@ mod test { "interval": 10080, "lastExecution": "2023-05-20T21:29:16Z", "nextExecution": "2023-05-20T21:29:16Z", - "lastDuration": "00:00:00.5111547", }]); let response: Vec = serde_json::from_value(tasks_json.clone()).unwrap(); let timestamp = DateTime::from(DateTime::parse_from_rfc3339("2023-05-20T21:29:16Z").unwrap()); @@ -3707,7 +3705,6 @@ mod test { interval: 360, last_execution: timestamp, next_execution: timestamp, - last_duration: "00:00:00.5111547".to_owned(), }, SonarrTask { name: "Backup".to_owned(), @@ -3715,7 +3712,6 @@ mod test { interval: 10080, last_execution: timestamp, next_execution: timestamp, - last_duration: "00:00:00.5111547".to_owned(), }, ]; let (async_server, app_arc, _server) = mock_servarr_api( From 2dce587ea8f654b508baa5e2aa4aa5fad0a319e3 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Fri, 22 Nov 2024 17:18:47 -0700 Subject: [PATCH 069/119] feat(network): Added the ability to test an individual indexer in Sonarr --- src/models/servarr_data/sonarr/sonarr_data.rs | 2 + .../servarr_data/sonarr/sonarr_data_tests.rs | 1 + src/network/radarr_network.rs | 4 +- src/network/radarr_network_tests.rs | 6 +- src/network/sonarr_network.rs | 65 ++++++ src/network/sonarr_network_tests.rs | 205 ++++++++++++++++++ 6 files changed, 278 insertions(+), 5 deletions(-) diff --git a/src/models/servarr_data/sonarr/sonarr_data.rs b/src/models/servarr_data/sonarr/sonarr_data.rs index f497a86..26d3c37 100644 --- a/src/models/servarr_data/sonarr/sonarr_data.rs +++ b/src/models/servarr_data/sonarr/sonarr_data.rs @@ -26,6 +26,7 @@ pub struct SonarrData { pub history: StatefulTable, pub indexers: StatefulTable, pub indexer_settings: Option, + pub indexer_test_error: Option, pub logs: StatefulList, pub quality_profile_map: BiMap, pub queued_events: StatefulTable, @@ -51,6 +52,7 @@ impl Default for SonarrData { history: StatefulTable::default(), indexers: StatefulTable::default(), indexer_settings: None, + indexer_test_error: None, logs: StatefulList::default(), quality_profile_map: BiMap::new(), queued_events: StatefulTable::default(), diff --git a/src/models/servarr_data/sonarr/sonarr_data_tests.rs b/src/models/servarr_data/sonarr/sonarr_data_tests.rs index ca44f74..f7e1c70 100644 --- a/src/models/servarr_data/sonarr/sonarr_data_tests.rs +++ b/src/models/servarr_data/sonarr/sonarr_data_tests.rs @@ -41,6 +41,7 @@ mod tests { assert!(sonarr_data.history.is_empty()); assert!(sonarr_data.indexers.is_empty()); assert!(sonarr_data.indexer_settings.is_none()); + assert!(sonarr_data.indexer_test_error.is_none()); assert!(sonarr_data.logs.is_empty()); assert!(sonarr_data.quality_profile_map.is_empty()); assert!(sonarr_data.queued_events.is_empty()); diff --git a/src/network/radarr_network.rs b/src/network/radarr_network.rs index e2c629b..f38bdd8 100644 --- a/src/network/radarr_network.rs +++ b/src/network/radarr_network.rs @@ -256,7 +256,7 @@ impl<'a, 'b> Network<'a, 'b> { .await .map(RadarrSerdeable::from), RadarrEvent::TestIndexer(indexer_id) => self - .test_indexer(indexer_id) + .test_radarr_indexer(indexer_id) .await .map(RadarrSerdeable::from), RadarrEvent::TestAllIndexers => self.test_all_indexers().await.map(RadarrSerdeable::from), @@ -2036,7 +2036,7 @@ impl<'a, 'b> Network<'a, 'b> { .await } - async fn test_indexer(&mut self, indexer_id: Option) -> Result { + async fn test_radarr_indexer(&mut self, indexer_id: Option) -> Result { let detail_event = RadarrEvent::GetIndexers; let event = RadarrEvent::TestIndexer(None); let id = if let Some(i_id) = indexer_id { diff --git a/src/network/radarr_network_tests.rs b/src/network/radarr_network_tests.rs index edda118..1f090f2 100644 --- a/src/network/radarr_network_tests.rs +++ b/src/network/radarr_network_tests.rs @@ -862,7 +862,7 @@ mod test { } #[tokio::test] - async fn test_handle_test_indexer_event_error() { + async fn test_handle_test_radarr_indexer_event_error() { let indexer_details_json = json!({ "enableRss": true, "enableAutomaticSearch": true, @@ -938,7 +938,7 @@ mod test { } #[tokio::test] - async fn test_handle_test_indexer_event_success() { + async fn test_handle_test_radarr_indexer_event_success() { let indexer_details_json = json!({ "enableRss": true, "enableAutomaticSearch": true, @@ -1007,7 +1007,7 @@ mod test { } #[tokio::test] - async fn test_handle_test_indexer_event_success_uses_provided_id() { + async fn test_handle_test_radarr_indexer_event_success_uses_provided_id() { let indexer_details_json = json!({ "enableRss": true, "enableAutomaticSearch": true, diff --git a/src/network/sonarr_network.rs b/src/network/sonarr_network.rs index 9aa4b62..ff44911 100644 --- a/src/network/sonarr_network.rs +++ b/src/network/sonarr_network.rs @@ -67,6 +67,7 @@ pub enum SonarrEvent { ListSeries, MarkHistoryItemAsFailed(i64), StartTask(Option), + TestIndexer(Option), } impl NetworkResource for SonarrEvent { @@ -98,6 +99,7 @@ impl NetworkResource for SonarrEvent { SonarrEvent::ListSeries | SonarrEvent::GetSeriesDetails(_) => "/series", SonarrEvent::MarkHistoryItemAsFailed(_) => "/history/failed", SonarrEvent::StartTask(_) => "/command", + SonarrEvent::TestIndexer(_) => "/indexer/test", } } } @@ -224,6 +226,10 @@ impl<'a, 'b> Network<'a, 'b> { .start_sonarr_task(task_name) .await .map(SonarrSerdeable::from), + SonarrEvent::TestIndexer(indexer_id) => self + .test_sonarr_indexer(indexer_id) + .await + .map(SonarrSerdeable::from), } } @@ -1311,6 +1317,65 @@ impl<'a, 'b> Network<'a, 'b> { .await } + async fn test_sonarr_indexer(&mut self, indexer_id: Option) -> Result { + let detail_event = SonarrEvent::GetIndexers; + let event = SonarrEvent::TestIndexer(None); + let id = if let Some(i_id) = indexer_id { + i_id + } else { + self + .app + .lock() + .await + .data + .sonarr_data + .indexers + .current_selection() + .id + }; + info!("Testing Sonarr indexer with ID: {id}"); + + info!("Fetching indexer details for indexer with ID: {id}"); + + let request_props = self + .request_props_from( + detail_event, + RequestMethod::Get, + None::<()>, + Some(format!("/{id}")), + None, + ) + .await; + + let mut test_body: Value = Value::default(); + + self + .handle_request::<(), Value>(request_props, |detailed_indexer_body, _| { + test_body = detailed_indexer_body; + }) + .await?; + + info!("Testing indexer"); + + let mut request_props = self + .request_props_from(event, RequestMethod::Post, Some(test_body), None, None) + .await; + request_props.ignore_status_code = true; + + self + .handle_request::(request_props, |test_results, mut app| { + if test_results.as_object().is_none() { + app.data.sonarr_data.indexer_test_error = Some( + test_results.as_array().unwrap()[0] + .get("errorMessage") + .unwrap() + .to_string(), + ); + }; + }) + .await + } + async fn extract_series_id(&mut self, series_id: Option) -> (i64, String) { let series_id = if let Some(id) = series_id { id diff --git a/src/network/sonarr_network_tests.rs b/src/network/sonarr_network_tests.rs index d247564..b6b2025 100644 --- a/src/network/sonarr_network_tests.rs +++ b/src/network/sonarr_network_tests.rs @@ -5,6 +5,7 @@ mod test { use bimap::BiMap; use chrono::{DateTime, Utc}; use indoc::formatdoc; + use mockito::Matcher; use pretty_assertions::{assert_eq, assert_str_eq}; use reqwest::Client; use rstest::rstest; @@ -223,6 +224,7 @@ mod test { #[case(SonarrEvent::GetTasks, "/system/task")] #[case(SonarrEvent::GetUpdates, "/update")] #[case(SonarrEvent::MarkHistoryItemAsFailed(0), "/history/failed")] + #[case(SonarrEvent::TestIndexer(None), "/indexer/test")] fn test_resource(#[case] event: SonarrEvent, #[case] expected_uri: String) { assert_str_eq!(event.resource(), expected_uri); } @@ -3923,6 +3925,209 @@ mod test { } } + #[tokio::test] + async fn test_handle_test_sonarr_indexer_event_error() { + let indexer_details_json = json!({ + "enableRss": true, + "enableAutomaticSearch": true, + "enableInteractiveSearch": true, + "name": "Test Indexer", + "fields": [ + { + "name": "baseUrl", + "value": "https://test.com", + }, + { + "name": "apiKey", + "value": "", + }, + { + "name": "seedCriteria.seedRatio", + "value": "1.2", + }, + ], + "tags": [1], + "id": 1 + }); + let response_json = json!([ + { + "isWarning": false, + "propertyName": "", + "errorMessage": "test failure", + "severity": "error" + }]); + let (async_details_server, app_arc, mut server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(indexer_details_json.clone()), + None, + SonarrEvent::GetIndexers, + Some("/1"), + None, + ) + .await; + let async_test_server = server + .mock( + "POST", + format!("/api/v3{}", SonarrEvent::TestIndexer(None).resource()).as_str(), + ) + .with_status(400) + .match_header("X-Api-Key", "test1234") + .match_body(Matcher::Json(indexer_details_json.clone())) + .with_body(response_json.to_string()) + .create_async() + .await; + app_arc + .lock() + .await + .data + .sonarr_data + .indexers + .set_items(vec![indexer()]); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + if let SonarrSerdeable::Value(value) = network + .handle_sonarr_event(SonarrEvent::TestIndexer(None)) + .await + .unwrap() + { + async_details_server.assert_async().await; + async_test_server.assert_async().await; + assert_eq!( + app_arc.lock().await.data.sonarr_data.indexer_test_error, + Some("\"test failure\"".to_owned()) + ); + assert_eq!(value, response_json) + } + } + + #[tokio::test] + async fn test_handle_test_sonarr_indexer_event_success() { + let indexer_details_json = json!({ + "enableRss": true, + "enableAutomaticSearch": true, + "enableInteractiveSearch": true, + "name": "Test Indexer", + "fields": [ + { + "name": "baseUrl", + "value": "https://test.com", + }, + { + "name": "apiKey", + "value": "", + }, + { + "name": "seedCriteria.seedRatio", + "value": "1.2", + }, + ], + "tags": [1], + "id": 1 + }); + let (async_details_server, app_arc, mut server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(indexer_details_json.clone()), + None, + SonarrEvent::GetIndexers, + Some("/1"), + None, + ) + .await; + let async_test_server = server + .mock( + "POST", + format!("/api/v3{}", SonarrEvent::TestIndexer(None).resource()).as_str(), + ) + .with_status(200) + .match_header("X-Api-Key", "test1234") + .match_body(Matcher::Json(indexer_details_json.clone())) + .with_body("{}") + .create_async() + .await; + app_arc + .lock() + .await + .data + .sonarr_data + .indexers + .set_items(vec![indexer()]); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + if let SonarrSerdeable::Value(value) = network + .handle_sonarr_event(SonarrEvent::TestIndexer(None)) + .await + .unwrap() + { + async_details_server.assert_async().await; + async_test_server.assert_async().await; + assert_eq!( + app_arc.lock().await.data.sonarr_data.indexer_test_error, + None + ); + assert_eq!(value, json!({})); + } + } + + #[tokio::test] + async fn test_handle_test_sonarr_indexer_event_success_uses_provided_id() { + let indexer_details_json = json!({ + "enableRss": true, + "enableAutomaticSearch": true, + "enableInteractiveSearch": true, + "name": "Test Indexer", + "fields": [ + { + "name": "baseUrl", + "value": "https://test.com", + }, + { + "name": "apiKey", + "value": "", + }, + { + "name": "seedCriteria.seedRatio", + "value": "1.2", + }, + ], + "tags": [1], + "id": 1 + }); + let (async_details_server, app_arc, mut server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(indexer_details_json.clone()), + None, + SonarrEvent::GetIndexers, + Some("/1"), + None, + ) + .await; + let async_test_server = server + .mock( + "POST", + format!("/api/v3{}", SonarrEvent::TestIndexer(None).resource()).as_str(), + ) + .with_status(200) + .match_header("X-Api-Key", "test1234") + .match_body(Matcher::Json(indexer_details_json.clone())) + .with_body("{}") + .create_async() + .await; + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + if let SonarrSerdeable::Value(value) = network + .handle_sonarr_event(SonarrEvent::TestIndexer(Some(1))) + .await + .unwrap() + { + async_details_server.assert_async().await; + async_test_server.assert_async().await; + assert_eq!(value, json!({})); + } + } + #[tokio::test] async fn test_extract_series_id() { let app_arc = Arc::new(Mutex::new(App::default())); From 68830a8789b9df99e979545e6554dd4ae133bb8b Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Fri, 22 Nov 2024 17:22:41 -0700 Subject: [PATCH 070/119] feat(cli): Support for testing an individual Sonarr indexer --- src/cli/sonarr/mod.rs | 14 ++++++++ src/cli/sonarr/sonarr_command_tests.rs | 49 ++++++++++++++++++++++++++ 2 files changed, 63 insertions(+) diff --git a/src/cli/sonarr/mod.rs b/src/cli/sonarr/mod.rs index 870223e..421d560 100644 --- a/src/cli/sonarr/mod.rs +++ b/src/cli/sonarr/mod.rs @@ -90,6 +90,13 @@ pub enum SonarrCommand { )] task_name: SonarrTaskName, }, + #[command( + about = "Test the indexer with the given ID. Note that a successful test returns an empty JSON body; i.e. '{}'" + )] + TestIndexer { + #[arg(long, help = "The ID of the indexer to test", required = true)] + indexer_id: i64, + }, } impl From for Command { @@ -185,6 +192,13 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, SonarrCommand> for SonarrCliHandler<'a, ' .await?; serde_json::to_string_pretty(&resp)? } + SonarrCommand::TestIndexer { indexer_id } => { + let resp = self + .network + .handle_network_event(SonarrEvent::TestIndexer(Some(indexer_id)).into()) + .await?; + serde_json::to_string_pretty(&resp)? + } }; Ok(result) diff --git a/src/cli/sonarr/sonarr_command_tests.rs b/src/cli/sonarr/sonarr_command_tests.rs index fc0988e..57f1422 100644 --- a/src/cli/sonarr/sonarr_command_tests.rs +++ b/src/cli/sonarr/sonarr_command_tests.rs @@ -164,6 +164,30 @@ mod tests { assert!(result.is_ok()); } + + #[rstest] + fn test_test_indexer_requires_indexer_id() { + let result = Cli::command().try_get_matches_from(["managarr", "sonarr", "test-indexer"]); + + assert!(result.is_err()); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_test_indexer_requirements_satisfied() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "sonarr", + "test-indexer", + "--indexer-id", + "1", + ]); + + assert!(result.is_ok()); + } } mod handler { @@ -437,5 +461,30 @@ mod tests { assert!(result.is_ok()); } + + #[tokio::test] + async fn test_test_indexer_command() { + let expected_indexer_id = 1; + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::( + SonarrEvent::TestIndexer(Some(expected_indexer_id)).into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Sonarr(SonarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::default())); + let test_indexer_command = SonarrCommand::TestIndexer { indexer_id: 1 }; + + let result = SonarrCliHandler::with(&app_arc, test_indexer_command, &mut mock_network) + .handle() + .await; + + assert!(result.is_ok()); + } } } From 6896fcc13465928066474e43d2926fbe8c3a5b9e Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Fri, 22 Nov 2024 17:35:36 -0700 Subject: [PATCH 071/119] feat(network): Support for testing all Sonarr indexers at once --- .../test_all_indexers_handler_tests.rs | 6 +- src/models/servarr_data/mod.rs | 1 + src/models/servarr_data/modals.rs | 8 ++ src/models/servarr_data/radarr/modals.rs | 7 -- src/models/servarr_data/radarr/radarr_data.rs | 4 +- src/models/servarr_data/sonarr/sonarr_data.rs | 3 + .../servarr_data/sonarr/sonarr_data_tests.rs | 1 + src/models/sonarr_models.rs | 3 + src/models/sonarr_models_tests.rs | 16 +++ src/network/radarr_network.rs | 13 ++- src/network/radarr_network_tests.rs | 2 +- src/network/sonarr_network.rs | 63 ++++++++++- src/network/sonarr_network_tests.rs | 100 ++++++++++++++++++ .../indexers/test_all_indexers_ui.rs | 2 +- 14 files changed, 207 insertions(+), 22 deletions(-) create mode 100644 src/models/servarr_data/modals.rs diff --git a/src/handlers/radarr_handlers/indexers/test_all_indexers_handler_tests.rs b/src/handlers/radarr_handlers/indexers/test_all_indexers_handler_tests.rs index 4ac81cc..708e22d 100644 --- a/src/handlers/radarr_handlers/indexers/test_all_indexers_handler_tests.rs +++ b/src/handlers/radarr_handlers/indexers/test_all_indexers_handler_tests.rs @@ -5,7 +5,7 @@ mod tests { use crate::event::Key; use crate::handlers::radarr_handlers::indexers::test_all_indexers_handler::TestAllIndexersHandler; use crate::handlers::KeyEventHandler; - use crate::models::servarr_data::radarr::modals::IndexerTestResultModalItem; + use crate::models::servarr_data::modals::IndexerTestResultModalItem; use crate::models::servarr_data::radarr::radarr_data::ActiveRadarrBlock; use crate::models::stateful_table::StatefulTable; use strum::IntoEnumIterator; @@ -14,7 +14,7 @@ mod tests { use pretty_assertions::assert_str_eq; use rstest::rstest; - use crate::models::servarr_data::radarr::modals::IndexerTestResultModalItem; + use crate::models::servarr_data::modals::IndexerTestResultModalItem; use crate::models::stateful_table::StatefulTable; use crate::simple_stateful_iterable_vec; @@ -112,7 +112,7 @@ mod tests { mod test_handle_home_end { use crate::extended_stateful_iterable_vec; - use crate::models::servarr_data::radarr::modals::IndexerTestResultModalItem; + use crate::models::servarr_data::modals::IndexerTestResultModalItem; use crate::models::stateful_table::StatefulTable; use pretty_assertions::assert_str_eq; diff --git a/src/models/servarr_data/mod.rs b/src/models/servarr_data/mod.rs index a4ec084..c82e844 100644 --- a/src/models/servarr_data/mod.rs +++ b/src/models/servarr_data/mod.rs @@ -1,2 +1,3 @@ +pub mod modals; pub mod radarr; pub mod sonarr; diff --git a/src/models/servarr_data/modals.rs b/src/models/servarr_data/modals.rs new file mode 100644 index 0000000..84d8922 --- /dev/null +++ b/src/models/servarr_data/modals.rs @@ -0,0 +1,8 @@ +use crate::models::HorizontallyScrollableText; + +#[derive(Default, Clone, Eq, PartialEq, Debug)] +pub struct IndexerTestResultModalItem { + pub name: String, + pub is_valid: bool, + pub validation_failures: HorizontallyScrollableText, +} diff --git a/src/models/servarr_data/radarr/modals.rs b/src/models/servarr_data/radarr/modals.rs index 3f73e3e..2d7c893 100644 --- a/src/models/servarr_data/radarr/modals.rs +++ b/src/models/servarr_data/radarr/modals.rs @@ -291,10 +291,3 @@ impl From<&RadarrData<'_>> for EditCollectionModal { edit_collection_modal } } - -#[derive(Default, Clone, Eq, PartialEq, Debug)] -pub struct IndexerTestResultModalItem { - pub name: String, - pub is_valid: bool, - pub validation_failures: HorizontallyScrollableText, -} diff --git a/src/models/servarr_data/radarr/radarr_data.rs b/src/models/servarr_data/radarr/radarr_data.rs index 483a1a0..207c2e2 100644 --- a/src/models/servarr_data/radarr/radarr_data.rs +++ b/src/models/servarr_data/radarr/radarr_data.rs @@ -9,9 +9,9 @@ use crate::models::radarr_models::{ AddMovieSearchResult, BlocklistItem, Collection, CollectionMovie, DownloadRecord, IndexerSettings, Movie, RadarrTask, }; +use crate::models::servarr_data::modals::IndexerTestResultModalItem; use crate::models::servarr_data::radarr::modals::{ - AddMovieModal, EditCollectionModal, EditIndexerModal, EditMovieModal, IndexerTestResultModalItem, - MovieDetailsModal, + AddMovieModal, EditCollectionModal, EditIndexerModal, EditMovieModal, MovieDetailsModal, }; use crate::models::servarr_models::{DiskSpace, Indexer, QueueEvent, RootFolder}; use crate::models::stateful_list::StatefulList; diff --git a/src/models/servarr_data/sonarr/sonarr_data.rs b/src/models/servarr_data/sonarr/sonarr_data.rs index 26d3c37..6d44336 100644 --- a/src/models/servarr_data/sonarr/sonarr_data.rs +++ b/src/models/servarr_data/sonarr/sonarr_data.rs @@ -3,6 +3,7 @@ use chrono::{DateTime, Utc}; use strum::EnumIter; use crate::models::{ + servarr_data::modals::IndexerTestResultModalItem, servarr_models::{DiskSpace, Indexer, QueueEvent, RootFolder}, sonarr_models::{ BlocklistItem, DownloadRecord, IndexerSettings, Season, Series, SonarrHistoryItem, SonarrTask, @@ -26,6 +27,7 @@ pub struct SonarrData { pub history: StatefulTable, pub indexers: StatefulTable, pub indexer_settings: Option, + pub indexer_test_all_results: Option>, pub indexer_test_error: Option, pub logs: StatefulList, pub quality_profile_map: BiMap, @@ -53,6 +55,7 @@ impl Default for SonarrData { indexers: StatefulTable::default(), indexer_settings: None, indexer_test_error: None, + indexer_test_all_results: None, logs: StatefulList::default(), quality_profile_map: BiMap::new(), queued_events: StatefulTable::default(), diff --git a/src/models/servarr_data/sonarr/sonarr_data_tests.rs b/src/models/servarr_data/sonarr/sonarr_data_tests.rs index f7e1c70..b17150a 100644 --- a/src/models/servarr_data/sonarr/sonarr_data_tests.rs +++ b/src/models/servarr_data/sonarr/sonarr_data_tests.rs @@ -42,6 +42,7 @@ mod tests { assert!(sonarr_data.indexers.is_empty()); assert!(sonarr_data.indexer_settings.is_none()); assert!(sonarr_data.indexer_test_error.is_none()); + assert!(sonarr_data.indexer_test_all_results.is_none()); assert!(sonarr_data.logs.is_empty()); assert!(sonarr_data.quality_profile_map.is_empty()); assert!(sonarr_data.queued_events.is_empty()); diff --git a/src/models/sonarr_models.rs b/src/models/sonarr_models.rs index 8973709..b215a79 100644 --- a/src/models/sonarr_models.rs +++ b/src/models/sonarr_models.rs @@ -10,6 +10,7 @@ use strum::EnumIter; use crate::serde_enum_from; use super::{ + radarr_models::IndexerTestResult, servarr_models::{ DiskSpace, HostConfig, Indexer, Language, LogResponse, QualityProfile, QualityWrapper, QueueEvent, Release, RootFolder, SecurityConfig, Tag, Update, @@ -437,6 +438,7 @@ pub enum SonarrSerdeable { HostConfig(HostConfig), IndexerSettings(IndexerSettings), Indexers(Vec), + IndexerTestResults(Vec), LogResponse(LogResponse), QualityProfiles(Vec), QueueEvents(Vec), @@ -477,6 +479,7 @@ serde_enum_from!( HostConfig(HostConfig), IndexerSettings(IndexerSettings), Indexers(Vec), + IndexerTestResults(Vec), LogResponse(LogResponse), QualityProfiles(Vec), QueueEvents(Vec), diff --git a/src/models/sonarr_models_tests.rs b/src/models/sonarr_models_tests.rs index 1893ac6..a56af3d 100644 --- a/src/models/sonarr_models_tests.rs +++ b/src/models/sonarr_models_tests.rs @@ -4,6 +4,7 @@ mod tests { use serde_json::json; use crate::models::{ + radarr_models::IndexerTestResult, servarr_models::{ DiskSpace, HostConfig, Indexer, Log, LogResponse, QualityProfile, QueueEvent, Release, RootFolder, SecurityConfig, Tag, Update, @@ -438,4 +439,19 @@ mod tests { assert_eq!(sonarr_serdeable, SonarrSerdeable::Updates(updates)); } + + #[test] + fn test_sonarr_serdeable_from_indexer_test_results() { + let indexer_test_results = vec![IndexerTestResult { + id: 1, + ..IndexerTestResult::default() + }]; + + let sonarr_serdeable: SonarrSerdeable = indexer_test_results.clone().into(); + + assert_eq!( + sonarr_serdeable, + SonarrSerdeable::IndexerTestResults(indexer_test_results) + ); + } } diff --git a/src/network/radarr_network.rs b/src/network/radarr_network.rs index f38bdd8..dca19e9 100644 --- a/src/network/radarr_network.rs +++ b/src/network/radarr_network.rs @@ -12,9 +12,9 @@ use crate::models::radarr_models::{ EditIndexerParams, EditMovieParams, IndexerSettings, IndexerTestResult, Movie, MovieCommandBody, MovieHistoryItem, RadarrSerdeable, RadarrTask, RadarrTaskName, ReleaseDownloadBody, SystemStatus, }; +use crate::models::servarr_data::modals::IndexerTestResultModalItem; use crate::models::servarr_data::radarr::modals::{ - AddMovieModal, EditCollectionModal, EditIndexerModal, EditMovieModal, IndexerTestResultModalItem, - MovieDetailsModal, + AddMovieModal, EditCollectionModal, EditIndexerModal, EditMovieModal, MovieDetailsModal, }; use crate::models::servarr_data::radarr::radarr_data::ActiveRadarrBlock; use crate::models::servarr_models::{ @@ -259,7 +259,10 @@ impl<'a, 'b> Network<'a, 'b> { .test_radarr_indexer(indexer_id) .await .map(RadarrSerdeable::from), - RadarrEvent::TestAllIndexers => self.test_all_indexers().await.map(RadarrSerdeable::from), + RadarrEvent::TestAllIndexers => self + .test_all_radarr_indexers() + .await + .map(RadarrSerdeable::from), RadarrEvent::TriggerAutomaticSearch(movie_id) => self .trigger_automatic_search(movie_id) .await @@ -2095,8 +2098,8 @@ impl<'a, 'b> Network<'a, 'b> { .await } - async fn test_all_indexers(&mut self) -> Result> { - info!("Testing all indexers"); + async fn test_all_radarr_indexers(&mut self) -> Result> { + info!("Testing all Radarr indexers"); let event = RadarrEvent::TestAllIndexers; let mut request_props = self diff --git a/src/network/radarr_network_tests.rs b/src/network/radarr_network_tests.rs index 1f090f2..50e1234 100644 --- a/src/network/radarr_network_tests.rs +++ b/src/network/radarr_network_tests.rs @@ -1065,7 +1065,7 @@ mod test { } #[tokio::test] - async fn test_handle_test_all_indexers_event() { + async fn test_handle_test_all_radarr_indexers_event() { let indexers = vec![ Indexer { id: 1, diff --git a/src/network/sonarr_network.rs b/src/network/sonarr_network.rs index ff44911..270f700 100644 --- a/src/network/sonarr_network.rs +++ b/src/network/sonarr_network.rs @@ -5,9 +5,13 @@ use serde_json::{json, Value}; use crate::{ models::{ - servarr_data::sonarr::{ - modals::{EpisodeDetailsModal, SeasonDetailsModal}, - sonarr_data::ActiveSonarrBlock, + radarr_models::IndexerTestResult, + servarr_data::{ + modals::IndexerTestResultModalItem, + sonarr::{ + modals::{EpisodeDetailsModal, SeasonDetailsModal}, + sonarr_data::ActiveSonarrBlock, + }, }, servarr_models::{ AddRootFolderBody, CommandBody, DiskSpace, HostConfig, Indexer, LogResponse, QualityProfile, @@ -68,6 +72,7 @@ pub enum SonarrEvent { MarkHistoryItemAsFailed(i64), StartTask(Option), TestIndexer(Option), + TestAllIndexers, } impl NetworkResource for SonarrEvent { @@ -100,6 +105,7 @@ impl NetworkResource for SonarrEvent { SonarrEvent::MarkHistoryItemAsFailed(_) => "/history/failed", SonarrEvent::StartTask(_) => "/command", SonarrEvent::TestIndexer(_) => "/indexer/test", + SonarrEvent::TestAllIndexers => "/indexer/testall", } } } @@ -230,6 +236,10 @@ impl<'a, 'b> Network<'a, 'b> { .test_sonarr_indexer(indexer_id) .await .map(SonarrSerdeable::from), + SonarrEvent::TestAllIndexers => self + .test_all_sonarr_indexers() + .await + .map(SonarrSerdeable::from), } } @@ -1376,6 +1386,53 @@ impl<'a, 'b> Network<'a, 'b> { .await } + async fn test_all_sonarr_indexers(&mut self) -> Result> { + info!("Testing all Sonarr indexers"); + let event = SonarrEvent::TestAllIndexers; + + let mut request_props = self + .request_props_from(event, RequestMethod::Post, None, None, None) + .await; + request_props.ignore_status_code = true; + + self + .handle_request::<(), Vec>(request_props, |test_results, mut app| { + let mut test_all_indexer_results = StatefulTable::default(); + let indexers = app.data.sonarr_data.indexers.items.clone(); + let modal_test_results = test_results + .iter() + .map(|result| { + let name = indexers + .iter() + .filter(|&indexer| indexer.id == result.id) + .map(|indexer| indexer.name.clone()) + .nth(0) + .unwrap_or_default(); + let validation_failures = result + .validation_failures + .iter() + .map(|failure| { + format!( + "Failure for field '{}': {}", + failure.property_name, failure.error_message + ) + }) + .collect::>() + .join(", "); + + IndexerTestResultModalItem { + name: name.unwrap_or_default(), + is_valid: result.is_valid, + validation_failures: validation_failures.into(), + } + }) + .collect(); + test_all_indexer_results.set_items(modal_test_results); + app.data.sonarr_data.indexer_test_all_results = Some(test_all_indexer_results); + }) + .await + } + async fn extract_series_id(&mut self, series_id: Option) -> (i64, String) { let series_id = if let Some(id) = series_id { id diff --git a/src/network/sonarr_network_tests.rs b/src/network/sonarr_network_tests.rs index b6b2025..b7b67e0 100644 --- a/src/network/sonarr_network_tests.rs +++ b/src/network/sonarr_network_tests.rs @@ -15,6 +15,8 @@ mod test { use tokio_util::sync::CancellationToken; use crate::app::App; + use crate::models::radarr_models::IndexerTestResult; + use crate::models::servarr_data::modals::IndexerTestResultModalItem; use crate::models::servarr_data::sonarr::modals::{EpisodeDetailsModal, SeasonDetailsModal}; use crate::models::servarr_data::sonarr::sonarr_data::ActiveSonarrBlock; use crate::models::servarr_models::{ @@ -225,6 +227,7 @@ mod test { #[case(SonarrEvent::GetUpdates, "/update")] #[case(SonarrEvent::MarkHistoryItemAsFailed(0), "/history/failed")] #[case(SonarrEvent::TestIndexer(None), "/indexer/test")] + #[case(SonarrEvent::TestAllIndexers, "/indexer/testall")] fn test_resource(#[case] event: SonarrEvent, #[case] expected_uri: String) { assert_str_eq!(event.resource(), expected_uri); } @@ -4128,6 +4131,103 @@ mod test { } } + #[tokio::test] + async fn test_handle_test_all_sonarr_indexers_event() { + let indexers = vec![ + Indexer { + id: 1, + name: Some("Test 1".to_owned()), + ..Indexer::default() + }, + Indexer { + id: 2, + name: Some("Test 2".to_owned()), + ..Indexer::default() + }, + ]; + let indexer_test_results_modal_items = vec![ + IndexerTestResultModalItem { + name: "Test 1".to_owned(), + is_valid: true, + validation_failures: HorizontallyScrollableText::default(), + }, + IndexerTestResultModalItem { + name: "Test 2".to_owned(), + is_valid: false, + validation_failures: "Failure for field 'test field 1': test error message, Failure for field 'test field 2': test error message 2".into(), + }, + ]; + let response_json = json!([ + { + "id": 1, + "isValid": true, + "validationFailures": [] + }, + { + "id": 2, + "isValid": false, + "validationFailures": [ + { + "propertyName": "test field 1", + "errorMessage": "test error message", + "severity": "error" + }, + { + "propertyName": "test field 2", + "errorMessage": "test error message 2", + "severity": "error" + }, + ] + }]); + let response: Vec = serde_json::from_value(response_json.clone()).unwrap(); + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Post, + None, + Some(response_json), + Some(400), + SonarrEvent::TestAllIndexers, + None, + None, + ) + .await; + app_arc + .lock() + .await + .data + .sonarr_data + .indexers + .set_items(indexers); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + if let SonarrSerdeable::IndexerTestResults(results) = network + .handle_sonarr_event(SonarrEvent::TestAllIndexers) + .await + .unwrap() + { + async_server.assert_async().await; + assert!(app_arc + .lock() + .await + .data + .sonarr_data + .indexer_test_all_results + .is_some()); + assert_eq!( + app_arc + .lock() + .await + .data + .sonarr_data + .indexer_test_all_results + .as_ref() + .unwrap() + .items, + indexer_test_results_modal_items + ); + assert_eq!(results, response); + } + } + #[tokio::test] async fn test_extract_series_id() { let app_arc = Arc::new(Mutex::new(App::default())); diff --git a/src/ui/radarr_ui/indexers/test_all_indexers_ui.rs b/src/ui/radarr_ui/indexers/test_all_indexers_ui.rs index e1a1b59..1b36a45 100644 --- a/src/ui/radarr_ui/indexers/test_all_indexers_ui.rs +++ b/src/ui/radarr_ui/indexers/test_all_indexers_ui.rs @@ -1,6 +1,6 @@ use crate::app::context_clues::{build_context_clue_string, BARE_POPUP_CONTEXT_CLUES}; use crate::app::App; -use crate::models::servarr_data::radarr::modals::IndexerTestResultModalItem; +use crate::models::servarr_data::modals::IndexerTestResultModalItem; use crate::models::servarr_data::radarr::radarr_data::ActiveRadarrBlock; use crate::models::Route; use crate::ui::radarr_ui::indexers::draw_indexers; From 8807adea8368ece8027fa78c92a1432054d482ef Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Fri, 22 Nov 2024 17:38:11 -0700 Subject: [PATCH 072/119] feat(cli): Support for testing all Sonarr indexers at once --- src/cli/radarr/mod.rs | 3 ++- src/cli/sonarr/mod.rs | 10 ++++++++++ src/cli/sonarr/sonarr_command_tests.rs | 26 +++++++++++++++++++++++++- 3 files changed, 37 insertions(+), 2 deletions(-) diff --git a/src/cli/radarr/mod.rs b/src/cli/radarr/mod.rs index 623dd5a..c4b1675 100644 --- a/src/cli/radarr/mod.rs +++ b/src/cli/radarr/mod.rs @@ -116,7 +116,7 @@ pub enum RadarrCommand { #[arg(long, help = "The ID of the indexer to test", required = true)] indexer_id: i64, }, - #[command(about = "Test all indexers")] + #[command(about = "Test all Radarr indexers")] TestAllIndexers, #[command(about = "Trigger an automatic search for the movie with the specified ID")] TriggerAutomaticSearch { @@ -243,6 +243,7 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, RadarrCommand> for RadarrCliHandler<'a, ' serde_json::to_string_pretty(&resp)? } RadarrCommand::TestAllIndexers => { + println!("Testing all Radarr indexers. This may take a minute..."); let resp = self .network .handle_network_event(RadarrEvent::TestAllIndexers.into()) diff --git a/src/cli/sonarr/mod.rs b/src/cli/sonarr/mod.rs index 421d560..e8a84ee 100644 --- a/src/cli/sonarr/mod.rs +++ b/src/cli/sonarr/mod.rs @@ -97,6 +97,8 @@ pub enum SonarrCommand { #[arg(long, help = "The ID of the indexer to test", required = true)] indexer_id: i64, }, + #[command(about = "Test all Radarr indexers")] + TestAllIndexers, } impl From for Command { @@ -199,6 +201,14 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, SonarrCommand> for SonarrCliHandler<'a, ' .await?; serde_json::to_string_pretty(&resp)? } + SonarrCommand::TestAllIndexers => { + println!("Testing all Sonarr indexers. This may take a minute..."); + let resp = self + .network + .handle_network_event(SonarrEvent::TestAllIndexers.into()) + .await?; + serde_json::to_string_pretty(&resp)? + } }; Ok(result) diff --git a/src/cli/sonarr/sonarr_command_tests.rs b/src/cli/sonarr/sonarr_command_tests.rs index 57f1422..02da0f5 100644 --- a/src/cli/sonarr/sonarr_command_tests.rs +++ b/src/cli/sonarr/sonarr_command_tests.rs @@ -22,7 +22,9 @@ mod tests { use rstest::rstest; #[rstest] - fn test_commands_that_have_no_arg_requirements(#[values("clear-blocklist")] subcommand: &str) { + fn test_commands_that_have_no_arg_requirements( + #[values("clear-blocklist", "test-all-indexers")] subcommand: &str, + ) { let result = Cli::command().try_get_matches_from(["managarr", "sonarr", subcommand]); assert!(result.is_ok()); @@ -486,5 +488,27 @@ mod tests { assert!(result.is_ok()); } + + #[tokio::test] + async fn test_test_all_indexers_command() { + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::(SonarrEvent::TestAllIndexers.into())) + .times(1) + .returning(|_| { + Ok(Serdeable::Sonarr(SonarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::default())); + let test_all_indexers_command = SonarrCommand::TestAllIndexers; + + let result = SonarrCliHandler::with(&app_arc, test_all_indexers_command, &mut mock_network) + .handle() + .await; + + assert!(result.is_ok()); + } } } From 3497a54c3921152ab6a454b020b5096f2e856930 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Fri, 22 Nov 2024 17:53:09 -0700 Subject: [PATCH 073/119] feat(network): Support for triggering an automatic series search in Sonarr --- src/models/sonarr_models.rs | 12 ++++++ src/network/radarr_network.rs | 6 +-- src/network/radarr_network_tests.rs | 4 +- src/network/sonarr_network.rs | 33 +++++++++++++-- src/network/sonarr_network_tests.rs | 64 ++++++++++++++++++++++++++++- 5 files changed, 109 insertions(+), 10 deletions(-) diff --git a/src/models/sonarr_models.rs b/src/models/sonarr_models.rs index b215a79..5758bba 100644 --- a/src/models/sonarr_models.rs +++ b/src/models/sonarr_models.rs @@ -389,6 +389,18 @@ pub struct SonarrHistoryItem { pub data: SonarrHistoryData, } +#[derive(Default, Serialize, Debug)] +#[serde(rename_all = "camelCase")] +pub struct SonarrCommandBody { + pub name: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub series_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub season_number: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub episode_ids: Option>, +} + #[derive(Default, Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct SonarrTask { diff --git a/src/network/radarr_network.rs b/src/network/radarr_network.rs index dca19e9..6359cd7 100644 --- a/src/network/radarr_network.rs +++ b/src/network/radarr_network.rs @@ -264,7 +264,7 @@ impl<'a, 'b> Network<'a, 'b> { .await .map(RadarrSerdeable::from), RadarrEvent::TriggerAutomaticSearch(movie_id) => self - .trigger_automatic_search(movie_id) + .trigger_automatic_movie_search(movie_id) .await .map(RadarrSerdeable::from), RadarrEvent::UpdateAllMovies => self.update_all_movies().await.map(RadarrSerdeable::from), @@ -2145,8 +2145,8 @@ impl<'a, 'b> Network<'a, 'b> { .await } - async fn trigger_automatic_search(&mut self, movie_id: Option) -> Result { - let event = RadarrEvent::TriggerAutomaticSearch(None); + async fn trigger_automatic_movie_search(&mut self, movie_id: Option) -> Result { + let event = RadarrEvent::TriggerAutomaticSearch(movie_id); let (id, _) = self.extract_movie_id(movie_id).await; info!("Searching indexers for movie with ID: {id}"); let body = MovieCommandBody { diff --git a/src/network/radarr_network_tests.rs b/src/network/radarr_network_tests.rs index 50e1234..655daa4 100644 --- a/src/network/radarr_network_tests.rs +++ b/src/network/radarr_network_tests.rs @@ -1162,7 +1162,7 @@ mod test { } #[tokio::test] - async fn test_handle_trigger_automatic_search_event() { + async fn test_handle_trigger_automatic_movie_search_event() { let (async_server, app_arc, _server) = mock_servarr_api( RequestMethod::Post, Some(json!({ @@ -1194,7 +1194,7 @@ mod test { } #[tokio::test] - async fn test_handle_trigger_automatic_search_event_uses_provided_id() { + async fn test_handle_trigger_automatic_movie_search_event_uses_provided_id() { let (async_server, app_arc, _server) = mock_servarr_api( RequestMethod::Post, Some(json!({ diff --git a/src/network/sonarr_network.rs b/src/network/sonarr_network.rs index 270f700..f812859 100644 --- a/src/network/sonarr_network.rs +++ b/src/network/sonarr_network.rs @@ -19,8 +19,8 @@ use crate::{ }, sonarr_models::{ BlocklistResponse, DownloadRecord, DownloadsResponse, Episode, IndexerSettings, Series, - SonarrHistoryItem, SonarrHistoryWrapper, SonarrSerdeable, SonarrTask, SonarrTaskName, - SystemStatus, + SonarrCommandBody, SonarrHistoryItem, SonarrHistoryWrapper, SonarrSerdeable, SonarrTask, + SonarrTaskName, SystemStatus, }, stateful_table::StatefulTable, HorizontallyScrollableText, Route, Scrollable, ScrollableText, @@ -73,6 +73,7 @@ pub enum SonarrEvent { StartTask(Option), TestIndexer(Option), TestAllIndexers, + TriggerAutomaticSeriesSearch(Option), } impl NetworkResource for SonarrEvent { @@ -91,7 +92,9 @@ impl NetworkResource for SonarrEvent { SonarrEvent::GetLogs(_) => "/log", SonarrEvent::GetDiskSpace => "/diskspace", SonarrEvent::GetQualityProfiles => "/qualityprofile", - SonarrEvent::GetQueuedEvents => "/command", + SonarrEvent::GetQueuedEvents + | SonarrEvent::StartTask(_) + | SonarrEvent::TriggerAutomaticSeriesSearch(_) => "/command", SonarrEvent::GetRootFolders | SonarrEvent::DeleteRootFolder(_) | SonarrEvent::AddRootFolder(_) => "/rootfolder", @@ -103,7 +106,6 @@ impl NetworkResource for SonarrEvent { SonarrEvent::HealthCheck => "/health", SonarrEvent::ListSeries | SonarrEvent::GetSeriesDetails(_) => "/series", SonarrEvent::MarkHistoryItemAsFailed(_) => "/history/failed", - SonarrEvent::StartTask(_) => "/command", SonarrEvent::TestIndexer(_) => "/indexer/test", SonarrEvent::TestAllIndexers => "/indexer/testall", } @@ -240,6 +242,10 @@ impl<'a, 'b> Network<'a, 'b> { .test_all_sonarr_indexers() .await .map(SonarrSerdeable::from), + SonarrEvent::TriggerAutomaticSeriesSearch(series_id) => self + .trigger_automatic_series_search(series_id) + .await + .map(SonarrSerdeable::from), } } @@ -1433,6 +1439,25 @@ impl<'a, 'b> Network<'a, 'b> { .await } + async fn trigger_automatic_series_search(&mut self, series_id: Option) -> Result { + let event = SonarrEvent::TriggerAutomaticSeriesSearch(series_id); + let (id, _) = self.extract_series_id(series_id).await; + info!("Searching indexers for series with ID: {id}"); + let body = SonarrCommandBody { + name: "SeriesSearch".to_owned(), + series_id: Some(id), + ..SonarrCommandBody::default() + }; + + let request_props = self + .request_props_from(event, RequestMethod::Post, Some(body), None, None) + .await; + + self + .handle_request::(request_props, |_, _| ()) + .await + } + async fn extract_series_id(&mut self, series_id: Option) -> (i64, String) { let series_id = if let Some(id) = series_id { id diff --git a/src/network/sonarr_network_tests.rs b/src/network/sonarr_network_tests.rs index b7b67e0..e65dd1a 100644 --- a/src/network/sonarr_network_tests.rs +++ b/src/network/sonarr_network_tests.rs @@ -163,7 +163,12 @@ mod test { #[rstest] fn test_resource_command( - #[values(SonarrEvent::GetQueuedEvents, SonarrEvent::StartTask(None))] event: SonarrEvent, + #[values( + SonarrEvent::GetQueuedEvents, + SonarrEvent::StartTask(None), + SonarrEvent::TriggerAutomaticSeriesSearch(None) + )] + event: SonarrEvent, ) { assert_str_eq!(event.resource(), "/command"); } @@ -4228,6 +4233,63 @@ mod test { } } + #[tokio::test] + async fn test_handle_trigger_automatic_series_search_event() { + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Post, + Some(json!({ + "name": "SeriesSearch", + "seriesId": 1 + })), + Some(json!({})), + None, + SonarrEvent::TriggerAutomaticSeriesSearch(None), + None, + None, + ) + .await; + app_arc + .lock() + .await + .data + .sonarr_data + .series + .set_items(vec![series()]); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + assert!(network + .handle_sonarr_event(SonarrEvent::TriggerAutomaticSeriesSearch(None)) + .await + .is_ok()); + + async_server.assert_async().await; + } + + #[tokio::test] + async fn test_handle_trigger_automatic_series_search_event_uses_provided_id() { + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Post, + Some(json!({ + "name": "SeriesSearch", + "seriesId": 1 + })), + Some(json!({})), + None, + SonarrEvent::TriggerAutomaticSeriesSearch(None), + None, + None, + ) + .await; + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + assert!(network + .handle_sonarr_event(SonarrEvent::TriggerAutomaticSeriesSearch(Some(1))) + .await + .is_ok()); + + async_server.assert_async().await; + } + #[tokio::test] async fn test_extract_series_id() { let app_arc = Arc::new(Mutex::new(App::default())); From c754275af39dc29611d9c06fd238d6cb9d6f0e4c Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Fri, 22 Nov 2024 18:04:27 -0700 Subject: [PATCH 074/119] feat(network): Support for triggering an automatic season search in Sonarr --- src/network/sonarr_network.rs | 40 ++++++++- src/network/sonarr_network_tests.rs | 129 +++++++++++++++++++++++++++- 2 files changed, 167 insertions(+), 2 deletions(-) diff --git a/src/network/sonarr_network.rs b/src/network/sonarr_network.rs index f812859..88a505c 100644 --- a/src/network/sonarr_network.rs +++ b/src/network/sonarr_network.rs @@ -74,6 +74,7 @@ pub enum SonarrEvent { TestIndexer(Option), TestAllIndexers, TriggerAutomaticSeriesSearch(Option), + TriggerAutomaticSeasonSearch(Option<(i64, i64)>), } impl NetworkResource for SonarrEvent { @@ -94,7 +95,8 @@ impl NetworkResource for SonarrEvent { SonarrEvent::GetQualityProfiles => "/qualityprofile", SonarrEvent::GetQueuedEvents | SonarrEvent::StartTask(_) - | SonarrEvent::TriggerAutomaticSeriesSearch(_) => "/command", + | SonarrEvent::TriggerAutomaticSeriesSearch(_) + | SonarrEvent::TriggerAutomaticSeasonSearch(_) => "/command", SonarrEvent::GetRootFolders | SonarrEvent::DeleteRootFolder(_) | SonarrEvent::AddRootFolder(_) => "/rootfolder", @@ -246,6 +248,10 @@ impl<'a, 'b> Network<'a, 'b> { .trigger_automatic_series_search(series_id) .await .map(SonarrSerdeable::from), + SonarrEvent::TriggerAutomaticSeasonSearch(params) => self + .trigger_automatic_season_search(params) + .await + .map(SonarrSerdeable::from), } } @@ -1458,6 +1464,38 @@ impl<'a, 'b> Network<'a, 'b> { .await } + async fn trigger_automatic_season_search( + &mut self, + series_season_id_tuple: Option<(i64, i64)>, + ) -> Result { + let event = SonarrEvent::TriggerAutomaticSeasonSearch(series_season_id_tuple); + let (series_id, season_number) = + if let Some((series_id, season_number)) = series_season_id_tuple { + (Some(series_id), Some(season_number)) + } else { + (None, None) + }; + + let (series_id, _) = self.extract_series_id(series_id).await; + let (season_number, _) = self.extract_season_number(season_number).await; + info!("Searching indexers for series with ID: {series_id} and season number: {season_number}"); + + let body = SonarrCommandBody { + name: "SeasonSearch".to_owned(), + season_number: Some(season_number), + series_id: Some(series_id), + ..SonarrCommandBody::default() + }; + + let request_props = self + .request_props_from(event, RequestMethod::Post, Some(body), None, None) + .await; + + self + .handle_request::(request_props, |_, _| ()) + .await + } + async fn extract_series_id(&mut self, series_id: Option) -> (i64, String) { let series_id = if let Some(id) = series_id { id diff --git a/src/network/sonarr_network_tests.rs b/src/network/sonarr_network_tests.rs index e65dd1a..ebc24e8 100644 --- a/src/network/sonarr_network_tests.rs +++ b/src/network/sonarr_network_tests.rs @@ -166,7 +166,8 @@ mod test { #[values( SonarrEvent::GetQueuedEvents, SonarrEvent::StartTask(None), - SonarrEvent::TriggerAutomaticSeriesSearch(None) + SonarrEvent::TriggerAutomaticSeriesSearch(None), + SonarrEvent::TriggerAutomaticSeasonSearch(None) )] event: SonarrEvent, ) { @@ -4233,6 +4234,132 @@ mod test { } } + #[tokio::test] + async fn test_handle_trigger_automatic_season_search_event() { + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Post, + Some(json!({ + "name": "SeasonSearch", + "seriesId": 1, + "seasonNumber": 1 + })), + Some(json!({})), + None, + SonarrEvent::TriggerAutomaticSeasonSearch(None), + None, + None, + ) + .await; + app_arc + .lock() + .await + .data + .sonarr_data + .series + .set_items(vec![series()]); + app_arc + .lock() + .await + .data + .sonarr_data + .seasons + .set_items(vec![season()]); + app_arc.lock().await.data.sonarr_data.season_details_modal = + Some(SeasonDetailsModal::default()); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + assert!(network + .handle_sonarr_event(SonarrEvent::TriggerAutomaticSeasonSearch(None)) + .await + .is_ok()); + + async_server.assert_async().await; + } + + #[tokio::test] + async fn test_handle_trigger_automatic_season_search_event_uses_provided_series_id_and_season_number( + ) { + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Post, + Some(json!({ + "name": "SeasonSearch", + "seriesId": 2, + "seasonNumber": 2 + })), + Some(json!({})), + None, + SonarrEvent::TriggerAutomaticSeasonSearch(None), + None, + None, + ) + .await; + app_arc + .lock() + .await + .data + .sonarr_data + .series + .set_items(vec![series()]); + app_arc + .lock() + .await + .data + .sonarr_data + .seasons + .set_items(vec![season()]); + app_arc.lock().await.data.sonarr_data.season_details_modal = + Some(SeasonDetailsModal::default()); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + assert!(network + .handle_sonarr_event(SonarrEvent::TriggerAutomaticSeasonSearch(Some((2, 2)))) + .await + .is_ok()); + + async_server.assert_async().await; + } + + #[tokio::test] + async fn test_handle_trigger_automatic_season_search_event_filtered_series_and_filtered_seasons() + { + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Post, + Some(json!({ + "name": "SeasonSearch", + "seriesId": 1, + "seasonNumber": 1 + })), + Some(json!({})), + None, + SonarrEvent::TriggerAutomaticSeasonSearch(None), + None, + None, + ) + .await; + let mut filtered_series = StatefulTable::default(); + filtered_series.set_filtered_items(vec![Series { + id: 1, + ..Series::default() + }]); + app_arc.lock().await.data.sonarr_data.series = filtered_series; + let mut filtered_seasons = StatefulTable::default(); + filtered_seasons.set_filtered_items(vec![Season { + season_number: 1, + ..Season::default() + }]); + app_arc.lock().await.data.sonarr_data.seasons = filtered_seasons; + app_arc.lock().await.data.sonarr_data.season_details_modal = + Some(SeasonDetailsModal::default()); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + assert!(network + .handle_sonarr_event(SonarrEvent::TriggerAutomaticSeasonSearch(None)) + .await + .is_ok()); + + async_server.assert_async().await; + } + #[tokio::test] async fn test_handle_trigger_automatic_series_search_event() { let (async_server, app_arc, _server) = mock_servarr_api( From a11bce603dffa019457e9f150702dc0742e7e5d0 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Fri, 22 Nov 2024 18:18:23 -0700 Subject: [PATCH 075/119] feat(network): Support for triggering an automatic episode search in Sonarr --- src/network/sonarr_network.rs | 35 ++++++++++-- src/network/sonarr_network_tests.rs | 82 ++++++++++++++++++++++++++++- 2 files changed, 111 insertions(+), 6 deletions(-) diff --git a/src/network/sonarr_network.rs b/src/network/sonarr_network.rs index 88a505c..d5c7b92 100644 --- a/src/network/sonarr_network.rs +++ b/src/network/sonarr_network.rs @@ -73,8 +73,9 @@ pub enum SonarrEvent { StartTask(Option), TestIndexer(Option), TestAllIndexers, - TriggerAutomaticSeriesSearch(Option), + TriggerAutomaticEpisodeSearch(Option), TriggerAutomaticSeasonSearch(Option<(i64, i64)>), + TriggerAutomaticSeriesSearch(Option), } impl NetworkResource for SonarrEvent { @@ -96,7 +97,8 @@ impl NetworkResource for SonarrEvent { SonarrEvent::GetQueuedEvents | SonarrEvent::StartTask(_) | SonarrEvent::TriggerAutomaticSeriesSearch(_) - | SonarrEvent::TriggerAutomaticSeasonSearch(_) => "/command", + | SonarrEvent::TriggerAutomaticSeasonSearch(_) + | SonarrEvent::TriggerAutomaticEpisodeSearch(_) => "/command", SonarrEvent::GetRootFolders | SonarrEvent::DeleteRootFolder(_) | SonarrEvent::AddRootFolder(_) => "/rootfolder", @@ -244,12 +246,16 @@ impl<'a, 'b> Network<'a, 'b> { .test_all_sonarr_indexers() .await .map(SonarrSerdeable::from), + SonarrEvent::TriggerAutomaticSeasonSearch(params) => self + .trigger_automatic_season_search(params) + .await + .map(SonarrSerdeable::from), SonarrEvent::TriggerAutomaticSeriesSearch(series_id) => self .trigger_automatic_series_search(series_id) .await .map(SonarrSerdeable::from), - SonarrEvent::TriggerAutomaticSeasonSearch(params) => self - .trigger_automatic_season_search(params) + SonarrEvent::TriggerAutomaticEpisodeSearch(episode_id) => self + .trigger_automatic_episode_search(episode_id) .await .map(SonarrSerdeable::from), } @@ -1449,6 +1455,7 @@ impl<'a, 'b> Network<'a, 'b> { let event = SonarrEvent::TriggerAutomaticSeriesSearch(series_id); let (id, _) = self.extract_series_id(series_id).await; info!("Searching indexers for series with ID: {id}"); + let body = SonarrCommandBody { name: "SeriesSearch".to_owned(), series_id: Some(id), @@ -1496,6 +1503,26 @@ impl<'a, 'b> Network<'a, 'b> { .await } + async fn trigger_automatic_episode_search(&mut self, episode_id: Option) -> Result { + let event = SonarrEvent::TriggerAutomaticEpisodeSearch(episode_id); + let id = self.extract_episode_id(episode_id).await; + info!("Searching indexers for episode with ID: {id}"); + + let body = SonarrCommandBody { + name: "EpisodeSearch".to_owned(), + episode_ids: Some(vec![id]), + ..SonarrCommandBody::default() + }; + + let request_props = self + .request_props_from(event, RequestMethod::Post, Some(body), None, None) + .await; + + self + .handle_request::(request_props, |_, _| ()) + .await + } + async fn extract_series_id(&mut self, series_id: Option) -> (i64, String) { let series_id = if let Some(id) = series_id { id diff --git a/src/network/sonarr_network_tests.rs b/src/network/sonarr_network_tests.rs index ebc24e8..c6f13c1 100644 --- a/src/network/sonarr_network_tests.rs +++ b/src/network/sonarr_network_tests.rs @@ -166,8 +166,9 @@ mod test { #[values( SonarrEvent::GetQueuedEvents, SonarrEvent::StartTask(None), - SonarrEvent::TriggerAutomaticSeriesSearch(None), - SonarrEvent::TriggerAutomaticSeasonSearch(None) + SonarrEvent::TriggerAutomaticEpisodeSearch(None), + SonarrEvent::TriggerAutomaticSeasonSearch(None), + SonarrEvent::TriggerAutomaticSeriesSearch(None) )] event: SonarrEvent, ) { @@ -4234,6 +4235,83 @@ mod test { } } + #[tokio::test] + async fn test_handle_trigger_automatic_episode_search_event() { + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Post, + Some(json!({ + "name": "EpisodeSearch", + "episodeIds": [ 1 ] + })), + Some(json!({})), + None, + SonarrEvent::TriggerAutomaticEpisodeSearch(None), + None, + None, + ) + .await; + let mut season_details_modal = SeasonDetailsModal::default(); + season_details_modal.episodes.set_items(vec![episode()]); + app_arc.lock().await.data.sonarr_data.season_details_modal = Some(season_details_modal); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + assert!(network + .handle_sonarr_event(SonarrEvent::TriggerAutomaticEpisodeSearch(None)) + .await + .is_ok()); + + async_server.assert_async().await; + } + + #[tokio::test] + async fn test_handle_trigger_automatic_episode_search_event_uses_provided_id() { + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Post, + Some(json!({ + "name": "EpisodeSearch", + "episodeIds": [ 1 ] + })), + Some(json!({})), + None, + SonarrEvent::TriggerAutomaticEpisodeSearch(Some(1)), + None, + None, + ) + .await; + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + assert!(network + .handle_sonarr_event(SonarrEvent::TriggerAutomaticEpisodeSearch(Some(1))) + .await + .is_ok()); + + async_server.assert_async().await; + } + + #[tokio::test] + #[should_panic(expected = "Season details have not been loaded")] + async fn test_handle_trigger_automatic_episode_search_event_empty_season_details_modal_panics() { + let (_async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Post, + Some(json!({ + "name": "EpisodeSearch", + "episodeIds": [ 1 ] + })), + Some(json!({})), + None, + SonarrEvent::TriggerAutomaticEpisodeSearch(None), + None, + None, + ) + .await; + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + network + .handle_sonarr_event(SonarrEvent::TriggerAutomaticEpisodeSearch(None)) + .await + .unwrap(); + } + #[tokio::test] async fn test_handle_trigger_automatic_season_search_event() { let (async_server, app_arc, _server) = mock_servarr_api( From 74e9ea17acbfa8f882c0237e2a20d1ba3232b3de Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Fri, 22 Nov 2024 18:22:33 -0700 Subject: [PATCH 076/119] feat(cli): Support for triggering an automatic series search in Sonarr --- src/cli/sonarr/mod.rs | 16 +++++++ src/cli/sonarr/sonarr_command_tests.rs | 58 ++++++++++++++++++++++++++ 2 files changed, 74 insertions(+) diff --git a/src/cli/sonarr/mod.rs b/src/cli/sonarr/mod.rs index e8a84ee..54d651c 100644 --- a/src/cli/sonarr/mod.rs +++ b/src/cli/sonarr/mod.rs @@ -99,6 +99,15 @@ pub enum SonarrCommand { }, #[command(about = "Test all Radarr indexers")] TestAllIndexers, + #[command(about = "Trigger an automatic search for the series with the specified ID")] + TriggerAutomaticSeriesSearch { + #[arg( + long, + help = "The ID of the series you want to trigger an automatic search for", + required = true + )] + series_id: i64, + }, } impl From for Command { @@ -209,6 +218,13 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, SonarrCommand> for SonarrCliHandler<'a, ' .await?; serde_json::to_string_pretty(&resp)? } + SonarrCommand::TriggerAutomaticSeriesSearch { series_id } => { + let resp = self + .network + .handle_network_event(SonarrEvent::TriggerAutomaticSeriesSearch(Some(series_id)).into()) + .await?; + serde_json::to_string_pretty(&resp)? + } }; Ok(result) diff --git a/src/cli/sonarr/sonarr_command_tests.rs b/src/cli/sonarr/sonarr_command_tests.rs index 02da0f5..0ce54d0 100644 --- a/src/cli/sonarr/sonarr_command_tests.rs +++ b/src/cli/sonarr/sonarr_command_tests.rs @@ -190,6 +190,34 @@ mod tests { assert!(result.is_ok()); } + + #[rstest] + fn test_trigger_automatic_series_search_requires_series_id() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "sonarr", + "trigger-automatic-series-search", + ]); + + assert!(result.is_err()); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_trigger_automatic_series_search_requirements_satisfied() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "sonarr", + "trigger-automatic-series-search", + "--series-id", + "1", + ]); + + assert!(result.is_ok()); + } } mod handler { @@ -510,5 +538,35 @@ mod tests { assert!(result.is_ok()); } + + #[tokio::test] + async fn test_trigger_automatic_series_search_command() { + let expected_series_id = 1; + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::( + SonarrEvent::TriggerAutomaticSeriesSearch(Some(expected_series_id)).into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Sonarr(SonarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::default())); + let trigger_automatic_series_search_command = + SonarrCommand::TriggerAutomaticSeriesSearch { series_id: 1 }; + + let result = SonarrCliHandler::with( + &app_arc, + trigger_automatic_series_search_command, + &mut mock_network, + ) + .handle() + .await; + + assert!(result.is_ok()); + } } } From 40bb22ef7c6ceb8a0d8f8c9a43af925b7485ae68 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Fri, 22 Nov 2024 18:32:35 -0700 Subject: [PATCH 077/119] feat(cli): Support for triggering an automatic season search in Sonarr --- src/cli/radarr/add_command_handler_tests.rs | 1 + .../radarr/delete_command_handler_tests.rs | 1 + src/cli/radarr/edit_command_handler_tests.rs | 1 + src/cli/radarr/get_command_handler_tests.rs | 1 + src/cli/radarr/list_command_handler_tests.rs | 1 + .../radarr/refresh_command_handler_tests.rs | 1 + src/cli/sonarr/add_command_handler_tests.rs | 1 + .../sonarr/delete_command_handler_tests.rs | 1 + src/cli/sonarr/get_command_handler_tests.rs | 2 + src/cli/sonarr/list_command_handler_tests.rs | 1 + src/cli/sonarr/mod.rs | 25 ++++++ src/cli/sonarr/sonarr_command_tests.rs | 88 +++++++++++++++++++ 12 files changed, 124 insertions(+) diff --git a/src/cli/radarr/add_command_handler_tests.rs b/src/cli/radarr/add_command_handler_tests.rs index 8974454..59c9e29 100644 --- a/src/cli/radarr/add_command_handler_tests.rs +++ b/src/cli/radarr/add_command_handler_tests.rs @@ -10,6 +10,7 @@ mod tests { models::radarr_models::{MinimumAvailability, Monitor}, Cli, }; + use pretty_assertions::assert_eq; #[test] fn test_radarr_add_command_from() { diff --git a/src/cli/radarr/delete_command_handler_tests.rs b/src/cli/radarr/delete_command_handler_tests.rs index 7e20597..d8971f7 100644 --- a/src/cli/radarr/delete_command_handler_tests.rs +++ b/src/cli/radarr/delete_command_handler_tests.rs @@ -8,6 +8,7 @@ mod tests { Cli, }; use clap::{error::ErrorKind, CommandFactory, Parser}; + use pretty_assertions::assert_eq; #[test] fn test_radarr_delete_command_from() { diff --git a/src/cli/radarr/edit_command_handler_tests.rs b/src/cli/radarr/edit_command_handler_tests.rs index 3f77649..a960954 100644 --- a/src/cli/radarr/edit_command_handler_tests.rs +++ b/src/cli/radarr/edit_command_handler_tests.rs @@ -8,6 +8,7 @@ mod tests { Cli, }; use clap::{error::ErrorKind, CommandFactory, Parser}; + use pretty_assertions::assert_eq; #[test] fn test_radarr_edit_command_from() { diff --git a/src/cli/radarr/get_command_handler_tests.rs b/src/cli/radarr/get_command_handler_tests.rs index 0bc629f..e70db37 100644 --- a/src/cli/radarr/get_command_handler_tests.rs +++ b/src/cli/radarr/get_command_handler_tests.rs @@ -7,6 +7,7 @@ mod tests { use crate::cli::radarr::RadarrCommand; use crate::cli::Command; use crate::Cli; + use pretty_assertions::assert_eq; #[test] fn test_radarr_get_command_from() { diff --git a/src/cli/radarr/list_command_handler_tests.rs b/src/cli/radarr/list_command_handler_tests.rs index 080e0df..ca9aa0f 100644 --- a/src/cli/radarr/list_command_handler_tests.rs +++ b/src/cli/radarr/list_command_handler_tests.rs @@ -7,6 +7,7 @@ mod tests { use crate::cli::radarr::RadarrCommand; use crate::cli::Command; use crate::Cli; + use pretty_assertions::assert_eq; #[test] fn test_radarr_list_command_from() { diff --git a/src/cli/radarr/refresh_command_handler_tests.rs b/src/cli/radarr/refresh_command_handler_tests.rs index 2f8352a..30be57b 100644 --- a/src/cli/radarr/refresh_command_handler_tests.rs +++ b/src/cli/radarr/refresh_command_handler_tests.rs @@ -7,6 +7,7 @@ mod tests { use crate::cli::radarr::RadarrCommand; use crate::cli::Command; use crate::Cli; + use pretty_assertions::assert_eq; #[test] fn test_radarr_refresh_command_from() { diff --git a/src/cli/sonarr/add_command_handler_tests.rs b/src/cli/sonarr/add_command_handler_tests.rs index 25c244c..9a6f251 100644 --- a/src/cli/sonarr/add_command_handler_tests.rs +++ b/src/cli/sonarr/add_command_handler_tests.rs @@ -1,6 +1,7 @@ #[cfg(test)] mod tests { use clap::{error::ErrorKind, CommandFactory, Parser}; + use pretty_assertions::assert_eq; use crate::{ cli::{ diff --git a/src/cli/sonarr/delete_command_handler_tests.rs b/src/cli/sonarr/delete_command_handler_tests.rs index 8c66a4e..4400a90 100644 --- a/src/cli/sonarr/delete_command_handler_tests.rs +++ b/src/cli/sonarr/delete_command_handler_tests.rs @@ -8,6 +8,7 @@ mod tests { Cli, }; use clap::{error::ErrorKind, CommandFactory, Parser}; + use pretty_assertions::assert_eq; #[test] fn test_sonarr_delete_command_from() { diff --git a/src/cli/sonarr/get_command_handler_tests.rs b/src/cli/sonarr/get_command_handler_tests.rs index 714cc67..12a6225 100644 --- a/src/cli/sonarr/get_command_handler_tests.rs +++ b/src/cli/sonarr/get_command_handler_tests.rs @@ -6,6 +6,7 @@ mod tests { }; use crate::Cli; use clap::CommandFactory; + use pretty_assertions::assert_eq; #[test] fn test_sonarr_get_command_from() { @@ -18,6 +19,7 @@ mod tests { mod cli { use clap::error::ErrorKind; + use pretty_assertions::assert_eq; use super::*; diff --git a/src/cli/sonarr/list_command_handler_tests.rs b/src/cli/sonarr/list_command_handler_tests.rs index 886750e..0382f60 100644 --- a/src/cli/sonarr/list_command_handler_tests.rs +++ b/src/cli/sonarr/list_command_handler_tests.rs @@ -6,6 +6,7 @@ mod tests { }; use crate::Cli; use clap::CommandFactory; + use pretty_assertions::assert_eq; #[test] fn test_sonarr_list_command_from() { diff --git a/src/cli/sonarr/mod.rs b/src/cli/sonarr/mod.rs index 54d651c..81cfa48 100644 --- a/src/cli/sonarr/mod.rs +++ b/src/cli/sonarr/mod.rs @@ -108,6 +108,19 @@ pub enum SonarrCommand { )] series_id: i64, }, + #[command( + about = "Trigger an automatic search for the given season corresponding to the series with the given ID" + )] + TriggerAutomaticSeasonSearch { + #[arg( + long, + help = "The Sonarr ID of the series whose season you wish to trigger an automatic search for", + required = true + )] + series_id: i64, + #[arg(long, help = "The season number to search for", required = true)] + season_number: i64, + }, } impl From for Command { @@ -225,6 +238,18 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, SonarrCommand> for SonarrCliHandler<'a, ' .await?; serde_json::to_string_pretty(&resp)? } + SonarrCommand::TriggerAutomaticSeasonSearch { + series_id, + season_number, + } => { + let resp = self + .network + .handle_network_event( + SonarrEvent::TriggerAutomaticSeasonSearch(Some((series_id, season_number))).into(), + ) + .await?; + serde_json::to_string_pretty(&resp)? + } }; Ok(result) diff --git a/src/cli/sonarr/sonarr_command_tests.rs b/src/cli/sonarr/sonarr_command_tests.rs index 0ce54d0..b906ef8 100644 --- a/src/cli/sonarr/sonarr_command_tests.rs +++ b/src/cli/sonarr/sonarr_command_tests.rs @@ -6,6 +6,7 @@ mod tests { }; use crate::Cli; use clap::CommandFactory; + use pretty_assertions::assert_eq; #[test] fn test_sonarr_command_from() { @@ -19,6 +20,7 @@ mod tests { mod cli { use super::*; use clap::error::ErrorKind; + use pretty_assertions::assert_eq; use rstest::rstest; #[rstest] @@ -218,6 +220,55 @@ mod tests { assert!(result.is_ok()); } + + #[rstest] + fn test_trigger_automatic_season_search_requires_series_id() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "sonarr", + "trigger-automatic-season-search", + "--season-number", + "1", + ]); + + assert!(result.is_err()); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[rstest] + fn test_trigger_automatic_season_search_requires_season_number() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "sonarr", + "trigger-automatic-season-search", + "--series-id", + "1", + ]); + + assert!(result.is_err()); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_trigger_automatic_season_search_requirements_satisfied() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "sonarr", + "trigger-automatic-season-search", + "--series-id", + "1", + "--season-number", + "1", + ]); + + assert!(result.is_ok()); + } } mod handler { @@ -568,5 +619,42 @@ mod tests { assert!(result.is_ok()); } + + #[tokio::test] + async fn test_trigger_automatic_season_search_command() { + let expected_series_id = 1; + let expected_season_number = 1; + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::( + SonarrEvent::TriggerAutomaticSeasonSearch(Some(( + expected_series_id, + expected_season_number, + ))) + .into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Sonarr(SonarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::default())); + let trigger_automatic_season_search_command = SonarrCommand::TriggerAutomaticSeasonSearch { + series_id: 1, + season_number: 1, + }; + + let result = SonarrCliHandler::with( + &app_arc, + trigger_automatic_season_search_command, + &mut mock_network, + ) + .handle() + .await; + + assert!(result.is_ok()); + } } } From b8e4deb80f57d5e106a1776733da41460b7c4939 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Fri, 22 Nov 2024 18:35:38 -0700 Subject: [PATCH 078/119] feat(cli): Support for triggering an automatic episode search in Sonarr --- src/cli/sonarr/mod.rs | 16 +++++++ src/cli/sonarr/sonarr_command_tests.rs | 58 ++++++++++++++++++++++++++ 2 files changed, 74 insertions(+) diff --git a/src/cli/sonarr/mod.rs b/src/cli/sonarr/mod.rs index 81cfa48..409ef00 100644 --- a/src/cli/sonarr/mod.rs +++ b/src/cli/sonarr/mod.rs @@ -121,6 +121,15 @@ pub enum SonarrCommand { #[arg(long, help = "The season number to search for", required = true)] season_number: i64, }, + #[command(about = "Trigger an automatic search for the episode with the specified ID")] + TriggerAutomaticEpisodeSearch { + #[arg( + long, + help = "The ID of the episode you want to trigger an automatic search for", + required = true + )] + episode_id: i64, + }, } impl From for Command { @@ -250,6 +259,13 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, SonarrCommand> for SonarrCliHandler<'a, ' .await?; serde_json::to_string_pretty(&resp)? } + SonarrCommand::TriggerAutomaticEpisodeSearch { episode_id } => { + let resp = self + .network + .handle_network_event(SonarrEvent::TriggerAutomaticEpisodeSearch(Some(episode_id)).into()) + .await?; + serde_json::to_string_pretty(&resp)? + } }; Ok(result) diff --git a/src/cli/sonarr/sonarr_command_tests.rs b/src/cli/sonarr/sonarr_command_tests.rs index b906ef8..306525e 100644 --- a/src/cli/sonarr/sonarr_command_tests.rs +++ b/src/cli/sonarr/sonarr_command_tests.rs @@ -269,6 +269,34 @@ mod tests { assert!(result.is_ok()); } + + #[rstest] + fn test_trigger_automatic_episode_search_requires_episode_id() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "sonarr", + "trigger-automatic-episode-search", + ]); + + assert!(result.is_err()); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_trigger_automatic_episode_search_requirements_satisfied() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "sonarr", + "trigger-automatic-episode-search", + "--episode-id", + "1", + ]); + + assert!(result.is_ok()); + } } mod handler { @@ -656,5 +684,35 @@ mod tests { assert!(result.is_ok()); } + + #[tokio::test] + async fn test_trigger_automatic_episode_search_command() { + let expected_episode_id = 1; + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::( + SonarrEvent::TriggerAutomaticEpisodeSearch(Some(expected_episode_id)).into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Sonarr(SonarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::default())); + let trigger_automatic_episode_search_command = + SonarrCommand::TriggerAutomaticEpisodeSearch { episode_id: 1 }; + + let result = SonarrCliHandler::with( + &app_arc, + trigger_automatic_episode_search_command, + &mut mock_network, + ) + .handle() + .await; + + assert!(result.is_ok()); + } } } From 581975b9415bc693b0f4d379008e2178f923dfcb Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Fri, 22 Nov 2024 18:44:51 -0700 Subject: [PATCH 079/119] feat(network): Support for updating all series in Sonarr --- src/network/sonarr_network.rs | 22 +++++++++++++++++++++- src/network/sonarr_network_tests.rs | 27 ++++++++++++++++++++++++++- 2 files changed, 47 insertions(+), 2 deletions(-) diff --git a/src/network/sonarr_network.rs b/src/network/sonarr_network.rs index d5c7b92..13aaf5c 100644 --- a/src/network/sonarr_network.rs +++ b/src/network/sonarr_network.rs @@ -76,6 +76,7 @@ pub enum SonarrEvent { TriggerAutomaticEpisodeSearch(Option), TriggerAutomaticSeasonSearch(Option<(i64, i64)>), TriggerAutomaticSeriesSearch(Option), + UpdateAllSeries, } impl NetworkResource for SonarrEvent { @@ -98,7 +99,8 @@ impl NetworkResource for SonarrEvent { | SonarrEvent::StartTask(_) | SonarrEvent::TriggerAutomaticSeriesSearch(_) | SonarrEvent::TriggerAutomaticSeasonSearch(_) - | SonarrEvent::TriggerAutomaticEpisodeSearch(_) => "/command", + | SonarrEvent::TriggerAutomaticEpisodeSearch(_) + | SonarrEvent::UpdateAllSeries => "/command", SonarrEvent::GetRootFolders | SonarrEvent::DeleteRootFolder(_) | SonarrEvent::AddRootFolder(_) => "/rootfolder", @@ -258,6 +260,7 @@ impl<'a, 'b> Network<'a, 'b> { .trigger_automatic_episode_search(episode_id) .await .map(SonarrSerdeable::from), + SonarrEvent::UpdateAllSeries => self.update_all_series().await.map(SonarrSerdeable::from), } } @@ -1523,6 +1526,23 @@ impl<'a, 'b> Network<'a, 'b> { .await } + async fn update_all_series(&mut self) -> Result { + info!("Updating all series"); + let event = SonarrEvent::UpdateAllSeries; + let body = SonarrCommandBody { + name: "RefreshSeries".to_owned(), + ..SonarrCommandBody::default() + }; + + let request_props = self + .request_props_from(event, RequestMethod::Post, Some(body), None, None) + .await; + + self + .handle_request::(request_props, |_, _| ()) + .await + } + async fn extract_series_id(&mut self, series_id: Option) -> (i64, String) { let series_id = if let Some(id) = series_id { id diff --git a/src/network/sonarr_network_tests.rs b/src/network/sonarr_network_tests.rs index c6f13c1..efa9c57 100644 --- a/src/network/sonarr_network_tests.rs +++ b/src/network/sonarr_network_tests.rs @@ -168,7 +168,8 @@ mod test { SonarrEvent::StartTask(None), SonarrEvent::TriggerAutomaticEpisodeSearch(None), SonarrEvent::TriggerAutomaticSeasonSearch(None), - SonarrEvent::TriggerAutomaticSeriesSearch(None) + SonarrEvent::TriggerAutomaticSeriesSearch(None), + SonarrEvent::UpdateAllSeries )] event: SonarrEvent, ) { @@ -4495,6 +4496,30 @@ mod test { async_server.assert_async().await; } + #[tokio::test] + async fn test_handle_update_all_series_event() { + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Post, + Some(json!({ + "name": "RefreshSeries", + })), + Some(json!({})), + None, + SonarrEvent::UpdateAllSeries, + None, + None, + ) + .await; + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + assert!(network + .handle_sonarr_event(SonarrEvent::UpdateAllSeries) + .await + .is_ok()); + + async_server.assert_async().await; + } + #[tokio::test] async fn test_extract_series_id() { let app_arc = Arc::new(Mutex::new(App::default())); From 8864e2c86788be1ec62d46edaca6338ddad58c6b Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Fri, 22 Nov 2024 18:59:55 -0700 Subject: [PATCH 080/119] feat(cli): Support for refreshing all Sonarr series data --- src/cli/radarr/refresh_command_handler.rs | 2 +- .../radarr/refresh_command_handler_tests.rs | 2 +- src/cli/sonarr/mod.rs | 12 +++ src/cli/sonarr/refresh_command_handler.rs | 64 +++++++++++++++ .../sonarr/refresh_command_handler_tests.rs | 81 +++++++++++++++++++ src/cli/sonarr/sonarr_command_tests.rs | 26 ++++++ 6 files changed, 185 insertions(+), 2 deletions(-) create mode 100644 src/cli/sonarr/refresh_command_handler.rs create mode 100644 src/cli/sonarr/refresh_command_handler_tests.rs diff --git a/src/cli/radarr/refresh_command_handler.rs b/src/cli/radarr/refresh_command_handler.rs index 3101a98..f329249 100644 --- a/src/cli/radarr/refresh_command_handler.rs +++ b/src/cli/radarr/refresh_command_handler.rs @@ -18,7 +18,7 @@ mod refresh_command_handler_tests; #[derive(Debug, Clone, PartialEq, Eq, Subcommand)] pub enum RadarrRefreshCommand { - #[command(about = "Refresh all movie data for all movies in your library")] + #[command(about = "Refresh all movie data for all movies in your Radarr library")] AllMovies, #[command(about = "Refresh movie data and scan disk for the movie with the given ID")] Movie { diff --git a/src/cli/radarr/refresh_command_handler_tests.rs b/src/cli/radarr/refresh_command_handler_tests.rs index 30be57b..3c43830 100644 --- a/src/cli/radarr/refresh_command_handler_tests.rs +++ b/src/cli/radarr/refresh_command_handler_tests.rs @@ -82,7 +82,7 @@ mod tests { #[case(RadarrRefreshCommand::Collections, RadarrEvent::UpdateCollections)] #[case(RadarrRefreshCommand::Downloads, RadarrEvent::UpdateDownloads)] #[tokio::test] - async fn test_handle_list_blocklist_command( + async fn test_handle_refresh_command( #[case] refresh_command: RadarrRefreshCommand, #[case] expected_radarr_event: RadarrEvent, ) { diff --git a/src/cli/sonarr/mod.rs b/src/cli/sonarr/mod.rs index 409ef00..68b2b23 100644 --- a/src/cli/sonarr/mod.rs +++ b/src/cli/sonarr/mod.rs @@ -6,6 +6,7 @@ use clap::Subcommand; use delete_command_handler::{SonarrDeleteCommand, SonarrDeleteCommandHandler}; use get_command_handler::{SonarrGetCommand, SonarrGetCommandHandler}; use list_command_handler::{SonarrListCommand, SonarrListCommandHandler}; +use refresh_command_handler::{SonarrRefreshCommand, SonarrRefreshCommandHandler}; use tokio::sync::Mutex; use crate::{ @@ -20,6 +21,7 @@ mod add_command_handler; mod delete_command_handler; mod get_command_handler; mod list_command_handler; +mod refresh_command_handler; #[cfg(test)] #[path = "sonarr_command_tests.rs"] @@ -47,6 +49,11 @@ pub enum SonarrCommand { about = "Commands to list attributes from your Sonarr instance" )] List(SonarrListCommand), + #[command( + subcommand, + about = "Commands to refresh the data in your Sonarr instance" + )] + Refresh(SonarrRefreshCommand), #[command(about = "Clear the blocklist")] ClearBlocklist, #[command(about = "Mark the Sonarr history item with the given ID as 'failed'")] @@ -179,6 +186,11 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, SonarrCommand> for SonarrCliHandler<'a, ' .handle() .await? } + SonarrCommand::Refresh(update_command) => { + SonarrRefreshCommandHandler::with(self.app, update_command, self.network) + .handle() + .await? + } SonarrCommand::ClearBlocklist => { self .network diff --git a/src/cli/sonarr/refresh_command_handler.rs b/src/cli/sonarr/refresh_command_handler.rs new file mode 100644 index 0000000..2f50175 --- /dev/null +++ b/src/cli/sonarr/refresh_command_handler.rs @@ -0,0 +1,64 @@ +use std::sync::Arc; + +use clap::Subcommand; +use tokio::sync::Mutex; + +use crate::{ + app::App, + cli::{CliCommandHandler, Command}, + network::{sonarr_network::SonarrEvent, NetworkTrait}, +}; + +use super::SonarrCommand; + +#[cfg(test)] +#[path = "refresh_command_handler_tests.rs"] +mod refresh_command_handler_tests; + +#[derive(Debug, Clone, PartialEq, Eq, Subcommand)] +pub enum SonarrRefreshCommand { + #[command(about = "Refresh all series data for all series in your Sonarr library")] + AllSeries, +} + +impl From for Command { + fn from(value: SonarrRefreshCommand) -> Self { + Command::Sonarr(SonarrCommand::Refresh(value)) + } +} + +pub(super) struct SonarrRefreshCommandHandler<'a, 'b> { + _app: &'a Arc>>, + command: SonarrRefreshCommand, + network: &'a mut dyn NetworkTrait, +} + +impl<'a, 'b> CliCommandHandler<'a, 'b, SonarrRefreshCommand> + for SonarrRefreshCommandHandler<'a, 'b> +{ + fn with( + _app: &'a Arc>>, + command: SonarrRefreshCommand, + network: &'a mut dyn NetworkTrait, + ) -> Self { + SonarrRefreshCommandHandler { + _app, + command, + network, + } + } + + async fn handle(self) -> anyhow::Result { + let result = match self.command { + SonarrRefreshCommand::AllSeries => { + let resp = self + .network + .handle_network_event(SonarrEvent::UpdateAllSeries.into()) + .await?; + serde_json::to_string_pretty(&resp)? + } + }; + + Ok(result) + } +} diff --git a/src/cli/sonarr/refresh_command_handler_tests.rs b/src/cli/sonarr/refresh_command_handler_tests.rs new file mode 100644 index 0000000..f200424 --- /dev/null +++ b/src/cli/sonarr/refresh_command_handler_tests.rs @@ -0,0 +1,81 @@ +#[cfg(test)] +mod tests { + use pretty_assertions::assert_eq; + + use crate::cli::{ + sonarr::{refresh_command_handler::SonarrRefreshCommand, SonarrCommand}, + Command, + }; + use crate::Cli; + use clap::CommandFactory; + + #[test] + fn test_sonarr_refresh_command_from() { + let command = SonarrRefreshCommand::AllSeries; + + let result = Command::from(command.clone()); + + assert_eq!(result, Command::Sonarr(SonarrCommand::Refresh(command))); + } + + mod cli { + use super::*; + use clap::Parser; + use pretty_assertions::assert_eq; + use rstest::rstest; + + #[rstest] + fn test_refresh_commands_have_no_arg_requirements(#[values("all-series")] subcommand: &str) { + let result = + Cli::command().try_get_matches_from(["managarr", "sonarr", "refresh", subcommand]); + + assert!(result.is_ok()); + } + } + + mod handler { + use rstest::rstest; + use std::sync::Arc; + + use mockall::predicate::eq; + use serde_json::json; + use tokio::sync::Mutex; + + use crate::{ + cli::{sonarr::refresh_command_handler::SonarrRefreshCommand, CliCommandHandler}, + network::sonarr_network::SonarrEvent, + }; + use crate::{ + models::{sonarr_models::SonarrSerdeable, Serdeable}, + network::{MockNetworkTrait, NetworkEvent}, + }; + + #[rstest] + #[case(SonarrRefreshCommand::AllSeries, SonarrEvent::UpdateAllSeries)] + #[tokio::test] + async fn test_handle_refresh_command( + #[case] refresh_command: SonarrRefreshCommand, + #[case] expected_sonarr_event: SonarrEvent, + ) { + use crate::{app::App, cli::sonarr::refresh_command_handler::SonarrRefreshCommandHandler}; + + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::(expected_sonarr_event.into())) + .times(1) + .returning(|_| { + Ok(Serdeable::Sonarr(SonarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::default())); + + let result = SonarrRefreshCommandHandler::with(&app_arc, refresh_command, &mut mock_network) + .handle() + .await; + + assert!(result.is_ok()); + } + } +} diff --git a/src/cli/sonarr/sonarr_command_tests.rs b/src/cli/sonarr/sonarr_command_tests.rs index 306525e..36d0870 100644 --- a/src/cli/sonarr/sonarr_command_tests.rs +++ b/src/cli/sonarr/sonarr_command_tests.rs @@ -544,6 +544,32 @@ mod tests { assert!(result.is_ok()); } + // #[tokio::test] + // async fn test_sonarr_cli_handler_delegates_refresh_commands_to_the_refresh_command_handler() { + // let expected_series_id = 1; + // let mut mock_network = MockNetworkTrait::new(); + // mock_network + // .expect_handle_network_event() + // .with(eq::( + // SonarrEvent::UpdateAndScan(Some(expected_movie_id)).into(), + // )) + // .times(1) + // .returning(|_| { + // Ok(Serdeable::Sonarr(SonarrSerdeable::Value( + // json!({"testResponse": "response"}), + // ))) + // }); + // let app_arc = Arc::new(Mutex::new(App::default())); + // let refresh_all_series_command = + // SonarrCommand::Refresh(SonarrRefreshCommand::Movie { movie_id: 1 }); + + // let result = SonarrCliHandler::with(&app_arc, refresh_movie_command, &mut mock_network) + // .handle() + // .await; + + // assert!(result.is_ok()); + // } + #[tokio::test] async fn test_start_task_command() { let expected_task_name = SonarrTaskName::ApplicationUpdateCheck; From c3577a07243942055432e9f57adb650a450944fd Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Fri, 22 Nov 2024 19:08:13 -0700 Subject: [PATCH 081/119] feat(network): Support for updating and scanning a series in Sonarr --- src/network/radarr_network.rs | 4 +- src/network/radarr_network_tests.rs | 4 +- src/network/sonarr_network.rs | 27 ++++++++++++- src/network/sonarr_network_tests.rs | 60 ++++++++++++++++++++++++++++- 4 files changed, 89 insertions(+), 6 deletions(-) diff --git a/src/network/radarr_network.rs b/src/network/radarr_network.rs index 6359cd7..b8c46ad 100644 --- a/src/network/radarr_network.rs +++ b/src/network/radarr_network.rs @@ -269,7 +269,7 @@ impl<'a, 'b> Network<'a, 'b> { .map(RadarrSerdeable::from), RadarrEvent::UpdateAllMovies => self.update_all_movies().await.map(RadarrSerdeable::from), RadarrEvent::UpdateAndScan(movie_id) => self - .update_and_scan(movie_id) + .update_and_scan_movie(movie_id) .await .map(RadarrSerdeable::from), RadarrEvent::UpdateCollections => self.update_collections().await.map(RadarrSerdeable::from), @@ -2180,7 +2180,7 @@ impl<'a, 'b> Network<'a, 'b> { .await } - async fn update_and_scan(&mut self, movie_id: Option) -> Result { + async fn update_and_scan_movie(&mut self, movie_id: Option) -> Result { let (id, _) = self.extract_movie_id(movie_id).await; let event = RadarrEvent::UpdateAndScan(None); info!("Updating and scanning movie with ID: {id}"); diff --git a/src/network/radarr_network_tests.rs b/src/network/radarr_network_tests.rs index 655daa4..6d22fa7 100644 --- a/src/network/radarr_network_tests.rs +++ b/src/network/radarr_network_tests.rs @@ -1219,7 +1219,7 @@ mod test { } #[tokio::test] - async fn test_handle_update_and_scan_event() { + async fn test_handle_update_and_scan_movie_event() { let (async_server, app_arc, _server) = mock_servarr_api( RequestMethod::Post, Some(json!({ @@ -1251,7 +1251,7 @@ mod test { } #[tokio::test] - async fn test_handle_update_and_scan_event_uses_provied_movie_id() { + async fn test_handle_update_and_scan_movie_event_uses_provied_movie_id() { let (async_server, app_arc, _server) = mock_servarr_api( RequestMethod::Post, Some(json!({ diff --git a/src/network/sonarr_network.rs b/src/network/sonarr_network.rs index 13aaf5c..43729d1 100644 --- a/src/network/sonarr_network.rs +++ b/src/network/sonarr_network.rs @@ -77,6 +77,7 @@ pub enum SonarrEvent { TriggerAutomaticSeasonSearch(Option<(i64, i64)>), TriggerAutomaticSeriesSearch(Option), UpdateAllSeries, + UpdateAndScanSeries(Option), } impl NetworkResource for SonarrEvent { @@ -100,7 +101,8 @@ impl NetworkResource for SonarrEvent { | SonarrEvent::TriggerAutomaticSeriesSearch(_) | SonarrEvent::TriggerAutomaticSeasonSearch(_) | SonarrEvent::TriggerAutomaticEpisodeSearch(_) - | SonarrEvent::UpdateAllSeries => "/command", + | SonarrEvent::UpdateAllSeries + | SonarrEvent::UpdateAndScanSeries(_) => "/command", SonarrEvent::GetRootFolders | SonarrEvent::DeleteRootFolder(_) | SonarrEvent::AddRootFolder(_) => "/rootfolder", @@ -261,6 +263,10 @@ impl<'a, 'b> Network<'a, 'b> { .await .map(SonarrSerdeable::from), SonarrEvent::UpdateAllSeries => self.update_all_series().await.map(SonarrSerdeable::from), + SonarrEvent::UpdateAndScanSeries(series_id) => self + .update_and_scan_series(series_id) + .await + .map(SonarrSerdeable::from), } } @@ -1543,6 +1549,25 @@ impl<'a, 'b> Network<'a, 'b> { .await } + async fn update_and_scan_series(&mut self, series_id: Option) -> Result { + let (id, _) = self.extract_series_id(series_id).await; + let event = SonarrEvent::UpdateAndScanSeries(None); + info!("Updating and scanning series with ID: {id}"); + let body = SonarrCommandBody { + name: "RefreshSeries".to_owned(), + series_id: Some(id), + ..SonarrCommandBody::default() + }; + + let request_props = self + .request_props_from(event, RequestMethod::Post, Some(body), None, None) + .await; + + self + .handle_request::(request_props, |_, _| ()) + .await + } + async fn extract_series_id(&mut self, series_id: Option) -> (i64, String) { let series_id = if let Some(id) = series_id { id diff --git a/src/network/sonarr_network_tests.rs b/src/network/sonarr_network_tests.rs index efa9c57..e183f4f 100644 --- a/src/network/sonarr_network_tests.rs +++ b/src/network/sonarr_network_tests.rs @@ -169,7 +169,8 @@ mod test { SonarrEvent::TriggerAutomaticEpisodeSearch(None), SonarrEvent::TriggerAutomaticSeasonSearch(None), SonarrEvent::TriggerAutomaticSeriesSearch(None), - SonarrEvent::UpdateAllSeries + SonarrEvent::UpdateAllSeries, + SonarrEvent::UpdateAndScanSeries(None) )] event: SonarrEvent, ) { @@ -4520,6 +4521,63 @@ mod test { async_server.assert_async().await; } + #[tokio::test] + async fn test_handle_update_and_scan_series_event() { + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Post, + Some(json!({ + "name": "RefreshSeries", + "seriesId": 1, + })), + Some(json!({})), + None, + SonarrEvent::UpdateAndScanSeries(None), + None, + None, + ) + .await; + app_arc + .lock() + .await + .data + .sonarr_data + .series + .set_items(vec![series()]); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + assert!(network + .handle_sonarr_event(SonarrEvent::UpdateAndScanSeries(None)) + .await + .is_ok()); + + async_server.assert_async().await; + } + + #[tokio::test] + async fn test_handle_update_and_scan_series_event_uses_provied_series_id() { + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Post, + Some(json!({ + "name": "RefreshSeries", + "seriesId": 1 + })), + Some(json!({})), + None, + SonarrEvent::UpdateAndScanSeries(Some(1)), + None, + None, + ) + .await; + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + assert!(network + .handle_sonarr_event(SonarrEvent::UpdateAndScanSeries(Some(1))) + .await + .is_ok()); + + async_server.assert_async().await; + } + #[tokio::test] async fn test_extract_series_id() { let app_arc = Arc::new(Mutex::new(App::default())); From eb06787bb2222e56389a41c4979d87548a9cd1bb Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Fri, 22 Nov 2024 19:13:57 -0700 Subject: [PATCH 082/119] feat(cli): Support for refreshing a specific series in Sonarr --- src/cli/sonarr/refresh_command_handler.rs | 16 +++++ .../sonarr/refresh_command_handler_tests.rs | 63 ++++++++++++++++++- src/cli/sonarr/sonarr_command_tests.rs | 48 +++++++------- 3 files changed, 100 insertions(+), 27 deletions(-) diff --git a/src/cli/sonarr/refresh_command_handler.rs b/src/cli/sonarr/refresh_command_handler.rs index 2f50175..11077af 100644 --- a/src/cli/sonarr/refresh_command_handler.rs +++ b/src/cli/sonarr/refresh_command_handler.rs @@ -19,6 +19,15 @@ mod refresh_command_handler_tests; pub enum SonarrRefreshCommand { #[command(about = "Refresh all series data for all series in your Sonarr library")] AllSeries, + #[command(about = "Refresh series data and scan disk for the series with the given ID")] + Series { + #[arg( + long, + help = "The ID of the series to refresh information on and to scan the disk for", + required = true + )] + series_id: i64, + }, } impl From for Command { @@ -57,6 +66,13 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, SonarrRefreshCommand> .await?; serde_json::to_string_pretty(&resp)? } + SonarrRefreshCommand::Series { series_id } => { + let resp = self + .network + .handle_network_event(SonarrEvent::UpdateAndScanSeries(Some(series_id)).into()) + .await?; + serde_json::to_string_pretty(&resp)? + } }; Ok(result) diff --git a/src/cli/sonarr/refresh_command_handler_tests.rs b/src/cli/sonarr/refresh_command_handler_tests.rs index f200424..c1fd427 100644 --- a/src/cli/sonarr/refresh_command_handler_tests.rs +++ b/src/cli/sonarr/refresh_command_handler_tests.rs @@ -20,7 +20,7 @@ mod tests { mod cli { use super::*; - use clap::Parser; + use clap::{error::ErrorKind, Parser}; use pretty_assertions::assert_eq; use rstest::rstest; @@ -31,6 +31,38 @@ mod tests { assert!(result.is_ok()); } + + #[test] + fn test_refresh_series_requires_series_id() { + let result = Cli::command().try_get_matches_from(["managarr", "sonarr", "refresh", "series"]); + + assert!(result.is_err()); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_refresh_series_success() { + let expected_args = SonarrRefreshCommand::Series { series_id: 1 }; + let result = Cli::try_parse_from([ + "managarr", + "sonarr", + "refresh", + "series", + "--series-id", + "1", + ]); + + assert!(result.is_ok()); + + if let Some(Command::Sonarr(SonarrCommand::Refresh(refresh_command))) = + result.unwrap().command + { + assert_eq!(refresh_command, expected_args); + } + } } mod handler { @@ -41,6 +73,7 @@ mod tests { use serde_json::json; use tokio::sync::Mutex; + use crate::{app::App, cli::sonarr::refresh_command_handler::SonarrRefreshCommandHandler}; use crate::{ cli::{sonarr::refresh_command_handler::SonarrRefreshCommand, CliCommandHandler}, network::sonarr_network::SonarrEvent, @@ -57,8 +90,6 @@ mod tests { #[case] refresh_command: SonarrRefreshCommand, #[case] expected_sonarr_event: SonarrEvent, ) { - use crate::{app::App, cli::sonarr::refresh_command_handler::SonarrRefreshCommandHandler}; - let mut mock_network = MockNetworkTrait::new(); mock_network .expect_handle_network_event() @@ -77,5 +108,31 @@ mod tests { assert!(result.is_ok()); } + + #[tokio::test] + async fn test_handle_refresh_series_command() { + let expected_series_id = 1; + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::( + SonarrEvent::UpdateAndScanSeries(Some(expected_series_id)).into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Sonarr(SonarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::default())); + let refresh_series_command = SonarrRefreshCommand::Series { series_id: 1 }; + + let result = + SonarrRefreshCommandHandler::with(&app_arc, refresh_series_command, &mut mock_network) + .handle() + .await; + + assert!(result.is_ok()); + } } } diff --git a/src/cli/sonarr/sonarr_command_tests.rs b/src/cli/sonarr/sonarr_command_tests.rs index 36d0870..a5b580e 100644 --- a/src/cli/sonarr/sonarr_command_tests.rs +++ b/src/cli/sonarr/sonarr_command_tests.rs @@ -312,7 +312,7 @@ mod tests { sonarr::{ add_command_handler::SonarrAddCommand, delete_command_handler::SonarrDeleteCommand, get_command_handler::SonarrGetCommand, list_command_handler::SonarrListCommand, - SonarrCliHandler, SonarrCommand, + refresh_command_handler::SonarrRefreshCommand, SonarrCliHandler, SonarrCommand, }, CliCommandHandler, }, @@ -544,31 +544,31 @@ mod tests { assert!(result.is_ok()); } - // #[tokio::test] - // async fn test_sonarr_cli_handler_delegates_refresh_commands_to_the_refresh_command_handler() { - // let expected_series_id = 1; - // let mut mock_network = MockNetworkTrait::new(); - // mock_network - // .expect_handle_network_event() - // .with(eq::( - // SonarrEvent::UpdateAndScan(Some(expected_movie_id)).into(), - // )) - // .times(1) - // .returning(|_| { - // Ok(Serdeable::Sonarr(SonarrSerdeable::Value( - // json!({"testResponse": "response"}), - // ))) - // }); - // let app_arc = Arc::new(Mutex::new(App::default())); - // let refresh_all_series_command = - // SonarrCommand::Refresh(SonarrRefreshCommand::Movie { movie_id: 1 }); + #[tokio::test] + async fn test_sonarr_cli_handler_delegates_refresh_commands_to_the_refresh_command_handler() { + let expected_series_id = 1; + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::( + SonarrEvent::UpdateAndScanSeries(Some(expected_series_id)).into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Sonarr(SonarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::default())); + let refresh_series_command = + SonarrCommand::Refresh(SonarrRefreshCommand::Series { series_id: 1 }); - // let result = SonarrCliHandler::with(&app_arc, refresh_movie_command, &mut mock_network) - // .handle() - // .await; + let result = SonarrCliHandler::with(&app_arc, refresh_series_command, &mut mock_network) + .handle() + .await; - // assert!(result.is_ok()); - // } + assert!(result.is_ok()); + } #[tokio::test] async fn test_start_task_command() { From 7fdec15ba9683e5c437ed849d59177cdad6eb0a7 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Fri, 22 Nov 2024 19:18:42 -0700 Subject: [PATCH 083/119] feat(network): Support for updating Sonarr downloads --- src/network/radarr_network.rs | 9 ++++++--- src/network/radarr_network_tests.rs | 2 +- src/network/sonarr_network.rs | 24 +++++++++++++++++++++++- src/network/sonarr_network_tests.rs | 27 ++++++++++++++++++++++++++- 4 files changed, 56 insertions(+), 6 deletions(-) diff --git a/src/network/radarr_network.rs b/src/network/radarr_network.rs index b8c46ad..51b3441 100644 --- a/src/network/radarr_network.rs +++ b/src/network/radarr_network.rs @@ -273,7 +273,10 @@ impl<'a, 'b> Network<'a, 'b> { .await .map(RadarrSerdeable::from), RadarrEvent::UpdateCollections => self.update_collections().await.map(RadarrSerdeable::from), - RadarrEvent::UpdateDownloads => self.update_downloads().await.map(RadarrSerdeable::from), + RadarrEvent::UpdateDownloads => self + .update_radarr_downloads() + .await + .map(RadarrSerdeable::from), } } @@ -2214,8 +2217,8 @@ impl<'a, 'b> Network<'a, 'b> { .await } - async fn update_downloads(&mut self) -> Result { - info!("Updating downloads"); + async fn update_radarr_downloads(&mut self) -> Result { + info!("Updating Radarr downloads"); let event = RadarrEvent::UpdateDownloads; let body = CommandBody { name: "RefreshMonitoredDownloads".to_owned(), diff --git a/src/network/radarr_network_tests.rs b/src/network/radarr_network_tests.rs index 6d22fa7..33e6f1e 100644 --- a/src/network/radarr_network_tests.rs +++ b/src/network/radarr_network_tests.rs @@ -1301,7 +1301,7 @@ mod test { } #[tokio::test] - async fn test_handle_update_downloads_event() { + async fn test_handle_update_radarr_downloads_event() { let (async_server, app_arc, _server) = mock_servarr_api( RequestMethod::Post, Some(json!({ diff --git a/src/network/sonarr_network.rs b/src/network/sonarr_network.rs index 43729d1..4603f88 100644 --- a/src/network/sonarr_network.rs +++ b/src/network/sonarr_network.rs @@ -78,6 +78,7 @@ pub enum SonarrEvent { TriggerAutomaticSeriesSearch(Option), UpdateAllSeries, UpdateAndScanSeries(Option), + UpdateDownloads, } impl NetworkResource for SonarrEvent { @@ -102,7 +103,8 @@ impl NetworkResource for SonarrEvent { | SonarrEvent::TriggerAutomaticSeasonSearch(_) | SonarrEvent::TriggerAutomaticEpisodeSearch(_) | SonarrEvent::UpdateAllSeries - | SonarrEvent::UpdateAndScanSeries(_) => "/command", + | SonarrEvent::UpdateAndScanSeries(_) + | SonarrEvent::UpdateDownloads => "/command", SonarrEvent::GetRootFolders | SonarrEvent::DeleteRootFolder(_) | SonarrEvent::AddRootFolder(_) => "/rootfolder", @@ -267,6 +269,10 @@ impl<'a, 'b> Network<'a, 'b> { .update_and_scan_series(series_id) .await .map(SonarrSerdeable::from), + SonarrEvent::UpdateDownloads => self + .update_sonarr_downloads() + .await + .map(SonarrSerdeable::from), } } @@ -1568,6 +1574,22 @@ impl<'a, 'b> Network<'a, 'b> { .await } + async fn update_sonarr_downloads(&mut self) -> Result { + info!("Updating Sonarr downloads"); + let event = SonarrEvent::UpdateDownloads; + let body = CommandBody { + name: "RefreshMonitoredDownloads".to_owned(), + }; + + let request_props = self + .request_props_from(event, RequestMethod::Post, Some(body), None, None) + .await; + + self + .handle_request::(request_props, |_, _| ()) + .await + } + async fn extract_series_id(&mut self, series_id: Option) -> (i64, String) { let series_id = if let Some(id) = series_id { id diff --git a/src/network/sonarr_network_tests.rs b/src/network/sonarr_network_tests.rs index e183f4f..aa3a254 100644 --- a/src/network/sonarr_network_tests.rs +++ b/src/network/sonarr_network_tests.rs @@ -170,7 +170,8 @@ mod test { SonarrEvent::TriggerAutomaticSeasonSearch(None), SonarrEvent::TriggerAutomaticSeriesSearch(None), SonarrEvent::UpdateAllSeries, - SonarrEvent::UpdateAndScanSeries(None) + SonarrEvent::UpdateAndScanSeries(None), + SonarrEvent::UpdateDownloads )] event: SonarrEvent, ) { @@ -4578,6 +4579,30 @@ mod test { async_server.assert_async().await; } + #[tokio::test] + async fn test_handle_update_sonarr_downloads_event() { + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Post, + Some(json!({ + "name": "RefreshMonitoredDownloads" + })), + Some(json!({})), + None, + SonarrEvent::UpdateDownloads, + None, + None, + ) + .await; + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + assert!(network + .handle_sonarr_event(SonarrEvent::UpdateDownloads) + .await + .is_ok()); + + async_server.assert_async().await; + } + #[tokio::test] async fn test_extract_series_id() { let app_arc = Arc::new(Mutex::new(App::default())); From cea4632a22768e5875dad4c0310e9330bd4e49be Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Fri, 22 Nov 2024 19:20:34 -0700 Subject: [PATCH 084/119] feat(cli): Support for refreshing Sonarr downloads --- src/cli/sonarr/refresh_command_handler.rs | 9 +++++++++ src/cli/sonarr/refresh_command_handler_tests.rs | 5 ++++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/src/cli/sonarr/refresh_command_handler.rs b/src/cli/sonarr/refresh_command_handler.rs index 11077af..418862f 100644 --- a/src/cli/sonarr/refresh_command_handler.rs +++ b/src/cli/sonarr/refresh_command_handler.rs @@ -28,6 +28,8 @@ pub enum SonarrRefreshCommand { )] series_id: i64, }, + #[command(about = "Refresh all downloads in Sonarr")] + Downloads, } impl From for Command { @@ -73,6 +75,13 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, SonarrRefreshCommand> .await?; serde_json::to_string_pretty(&resp)? } + SonarrRefreshCommand::Downloads => { + let resp = self + .network + .handle_network_event(SonarrEvent::UpdateDownloads.into()) + .await?; + serde_json::to_string_pretty(&resp)? + } }; Ok(result) diff --git a/src/cli/sonarr/refresh_command_handler_tests.rs b/src/cli/sonarr/refresh_command_handler_tests.rs index c1fd427..ce133d2 100644 --- a/src/cli/sonarr/refresh_command_handler_tests.rs +++ b/src/cli/sonarr/refresh_command_handler_tests.rs @@ -25,7 +25,9 @@ mod tests { use rstest::rstest; #[rstest] - fn test_refresh_commands_have_no_arg_requirements(#[values("all-series")] subcommand: &str) { + fn test_refresh_commands_have_no_arg_requirements( + #[values("all-series", "downloads")] subcommand: &str, + ) { let result = Cli::command().try_get_matches_from(["managarr", "sonarr", "refresh", subcommand]); @@ -85,6 +87,7 @@ mod tests { #[rstest] #[case(SonarrRefreshCommand::AllSeries, SonarrEvent::UpdateAllSeries)] + #[case(SonarrRefreshCommand::Downloads, SonarrEvent::UpdateDownloads)] #[tokio::test] async fn test_handle_refresh_command( #[case] refresh_command: SonarrRefreshCommand, From 896c50909a6422239e5e5bdf558fde31de040f61 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Fri, 22 Nov 2024 19:33:29 -0700 Subject: [PATCH 085/119] feat(network): Support for downloading releases from Sonarr --- src/cli/radarr/mod.rs | 4 +-- src/cli/radarr/radarr_command_tests.rs | 6 ++--- src/models/radarr_models.rs | 2 +- src/models/sonarr_models.rs | 13 ++++++++++ src/network/radarr_network.rs | 18 ++++++++------ src/network/radarr_network_tests.rs | 6 ++--- src/network/sonarr_network.rs | 32 ++++++++++++++++++++++-- src/network/sonarr_network_tests.rs | 34 +++++++++++++++++++++++++- 8 files changed, 96 insertions(+), 19 deletions(-) diff --git a/src/cli/radarr/mod.rs b/src/cli/radarr/mod.rs index c4b1675..6789380 100644 --- a/src/cli/radarr/mod.rs +++ b/src/cli/radarr/mod.rs @@ -12,7 +12,7 @@ use tokio::sync::Mutex; use crate::app::App; use crate::cli::CliCommandHandler; -use crate::models::radarr_models::{RadarrTaskName, ReleaseDownloadBody}; +use crate::models::radarr_models::{RadarrReleaseDownloadBody, RadarrTaskName}; use crate::network::radarr_network::RadarrEvent; use crate::network::NetworkTrait; use anyhow::Result; @@ -202,7 +202,7 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, RadarrCommand> for RadarrCliHandler<'a, ' indexer_id, movie_id, } => { - let params = ReleaseDownloadBody { + let params = RadarrReleaseDownloadBody { guid, indexer_id, movie_id, diff --git a/src/cli/radarr/radarr_command_tests.rs b/src/cli/radarr/radarr_command_tests.rs index fcb59d2..546dd70 100644 --- a/src/cli/radarr/radarr_command_tests.rs +++ b/src/cli/radarr/radarr_command_tests.rs @@ -261,8 +261,8 @@ mod tests { }, models::{ radarr_models::{ - BlocklistItem, BlocklistResponse, IndexerSettings, RadarrSerdeable, RadarrTaskName, - ReleaseDownloadBody, + BlocklistItem, BlocklistResponse, IndexerSettings, RadarrReleaseDownloadBody, + RadarrSerdeable, RadarrTaskName, }, Serdeable, }, @@ -304,7 +304,7 @@ mod tests { #[tokio::test] async fn test_download_release_command() { - let expected_release_download_body = ReleaseDownloadBody { + let expected_release_download_body = RadarrReleaseDownloadBody { guid: "guid".to_owned(), indexer_id: 1, movie_id: 1, diff --git a/src/models/radarr_models.rs b/src/models/radarr_models.rs index 080ae3f..5675502 100644 --- a/src/models/radarr_models.rs +++ b/src/models/radarr_models.rs @@ -414,7 +414,7 @@ pub struct RatingsList { #[derive(Default, Serialize, Debug, PartialEq, Eq, Clone)] #[serde(rename_all = "camelCase")] -pub struct ReleaseDownloadBody { +pub struct RadarrReleaseDownloadBody { pub guid: String, pub indexer_id: i64, pub movie_id: i64, diff --git a/src/models/sonarr_models.rs b/src/models/sonarr_models.rs index 5758bba..92cd9e8 100644 --- a/src/models/sonarr_models.rs +++ b/src/models/sonarr_models.rs @@ -401,6 +401,19 @@ pub struct SonarrCommandBody { pub episode_ids: Option>, } +#[derive(Default, Serialize, Debug, PartialEq, Eq, Clone)] +#[serde(rename_all = "camelCase")] +pub struct SonarrReleaseDownloadBody { + pub guid: String, + pub indexer_id: i64, + #[serde(skip_serializing_if = "Option::is_none")] + pub series_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub episode_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub season_number: Option, +} + #[derive(Default, Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct SonarrTask { diff --git a/src/network/radarr_network.rs b/src/network/radarr_network.rs index 51b3441..e195118 100644 --- a/src/network/radarr_network.rs +++ b/src/network/radarr_network.rs @@ -10,7 +10,8 @@ use crate::models::radarr_models::{ AddMovieBody, AddMovieSearchResult, AddOptions, BlocklistResponse, Collection, CollectionMovie, Credit, CreditType, DeleteMovieParams, DownloadRecord, DownloadsResponse, EditCollectionParams, EditIndexerParams, EditMovieParams, IndexerSettings, IndexerTestResult, Movie, MovieCommandBody, - MovieHistoryItem, RadarrSerdeable, RadarrTask, RadarrTaskName, ReleaseDownloadBody, SystemStatus, + MovieHistoryItem, RadarrReleaseDownloadBody, RadarrSerdeable, RadarrTask, RadarrTaskName, + SystemStatus, }; use crate::models::servarr_data::modals::IndexerTestResultModalItem; use crate::models::servarr_data::radarr::modals::{ @@ -44,7 +45,7 @@ pub enum RadarrEvent { DeleteMovie(Option), DeleteRootFolder(Option), DeleteTag(i64), - DownloadRelease(Option), + DownloadRelease(Option), EditAllIndexerSettings(Option), EditCollection(Option), EditIndexer(Option), @@ -176,7 +177,7 @@ impl<'a, 'b> Network<'a, 'b> { .await .map(RadarrSerdeable::from), RadarrEvent::DownloadRelease(params) => self - .download_release(params) + .download_radarr_release(params) .await .map(RadarrSerdeable::from), RadarrEvent::EditAllIndexerSettings(params) => self @@ -662,10 +663,13 @@ impl<'a, 'b> Network<'a, 'b> { .await } - async fn download_release(&mut self, params: Option) -> Result { + async fn download_radarr_release( + &mut self, + params: Option, + ) -> Result { let event = RadarrEvent::DownloadRelease(None); let body = if let Some(release_download_body) = params { - info!("Downloading release with params: {release_download_body:?}"); + info!("Downloading Radarr release with params: {release_download_body:?}"); release_download_body } else { let (movie_id, _) = self.extract_movie_id(None).await; @@ -690,7 +694,7 @@ impl<'a, 'b> Network<'a, 'b> { info!("Downloading release: {title}"); - ReleaseDownloadBody { + RadarrReleaseDownloadBody { guid, indexer_id, movie_id, @@ -702,7 +706,7 @@ impl<'a, 'b> Network<'a, 'b> { .await; self - .handle_request::(request_props, |_, _| ()) + .handle_request::(request_props, |_, _| ()) .await } diff --git a/src/network/radarr_network_tests.rs b/src/network/radarr_network_tests.rs index 33e6f1e..75703eb 100644 --- a/src/network/radarr_network_tests.rs +++ b/src/network/radarr_network_tests.rs @@ -4805,7 +4805,7 @@ mod test { } #[tokio::test] - async fn test_handle_download_release_event() { + async fn test_handle_download_radarr_release_event() { let (async_server, app_arc, _server) = mock_servarr_api( RequestMethod::Post, Some(json!({ @@ -4843,7 +4843,7 @@ mod test { } #[tokio::test] - async fn test_handle_download_release_event_uses_provided_params() { + async fn test_handle_download_radarr_release_event_uses_provided_params() { let (async_server, app_arc, _server) = mock_servarr_api( RequestMethod::Post, Some(json!({ @@ -4859,7 +4859,7 @@ mod test { ) .await; let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); - let params = ReleaseDownloadBody { + let params = RadarrReleaseDownloadBody { guid: "1234".to_owned(), indexer_id: 2, movie_id: 1, diff --git a/src/network/sonarr_network.rs b/src/network/sonarr_network.rs index 4603f88..1facb66 100644 --- a/src/network/sonarr_network.rs +++ b/src/network/sonarr_network.rs @@ -19,8 +19,8 @@ use crate::{ }, sonarr_models::{ BlocklistResponse, DownloadRecord, DownloadsResponse, Episode, IndexerSettings, Series, - SonarrCommandBody, SonarrHistoryItem, SonarrHistoryWrapper, SonarrSerdeable, SonarrTask, - SonarrTaskName, SystemStatus, + SonarrCommandBody, SonarrHistoryItem, SonarrHistoryWrapper, SonarrReleaseDownloadBody, + SonarrSerdeable, SonarrTask, SonarrTaskName, SystemStatus, }, stateful_table::StatefulTable, HorizontallyScrollableText, Route, Scrollable, ScrollableText, @@ -44,6 +44,7 @@ pub enum SonarrEvent { DeleteIndexer(Option), DeleteRootFolder(Option), DeleteTag(i64), + DownloadRelease(SonarrReleaseDownloadBody), GetAllIndexerSettings, GetBlocklist, GetDownloads, @@ -86,6 +87,7 @@ impl NetworkResource for SonarrEvent { match &self { SonarrEvent::AddTag(_) | SonarrEvent::DeleteTag(_) | SonarrEvent::GetTags => "/tag", SonarrEvent::ClearBlocklist => "/blocklist/bulk", + SonarrEvent::DownloadRelease(_) => "/release", SonarrEvent::DeleteBlocklistItem(_) => "/blocklist", SonarrEvent::GetAllIndexerSettings => "/config/indexer", SonarrEvent::GetBlocklist => "/blocklist?page=1&pageSize=10000", @@ -167,6 +169,10 @@ impl<'a, 'b> Network<'a, 'b> { .delete_sonarr_tag(tag_id) .await .map(SonarrSerdeable::from), + SonarrEvent::DownloadRelease(sonarr_release_download_body) => self + .download_sonarr_release(sonarr_release_download_body) + .await + .map(SonarrSerdeable::from), SonarrEvent::GetBlocklist => self.get_sonarr_blocklist().await.map(SonarrSerdeable::from), SonarrEvent::GetDownloads => self.get_sonarr_downloads().await.map(SonarrSerdeable::from), SonarrEvent::GetEpisodes(series_id) => self @@ -511,6 +517,28 @@ impl<'a, 'b> Network<'a, 'b> { .await } + async fn download_sonarr_release( + &mut self, + sonarr_release_download_body: SonarrReleaseDownloadBody, + ) -> Result { + let event = SonarrEvent::DownloadRelease(sonarr_release_download_body.clone()); + info!("Downloading Sonarr release with params: {sonarr_release_download_body:?}"); + + let request_props = self + .request_props_from( + event, + RequestMethod::Post, + Some(sonarr_release_download_body), + None, + None, + ) + .await; + + self + .handle_request::(request_props, |_, _| ()) + .await + } + async fn get_all_sonarr_indexer_settings(&mut self) -> Result { info!("Fetching Sonarr indexer settings"); let event = SonarrEvent::GetAllIndexerSettings; diff --git a/src/network/sonarr_network_tests.rs b/src/network/sonarr_network_tests.rs index aa3a254..260d44a 100644 --- a/src/network/sonarr_network_tests.rs +++ b/src/network/sonarr_network_tests.rs @@ -25,7 +25,7 @@ mod test { }; use crate::models::sonarr_models::{ BlocklistItem, DownloadRecord, DownloadsResponse, Episode, EpisodeFile, MediaInfo, - SonarrTaskName, + SonarrReleaseDownloadBody, SonarrTaskName, }; use crate::models::sonarr_models::{ BlocklistResponse, SonarrHistoryData, SonarrHistoryItem, SonarrHistoryWrapper, @@ -599,6 +599,38 @@ mod test { async_server.assert_async().await; } + #[tokio::test] + async fn test_handle_download_sonarr_release_event_uses_provided_params() { + let params = SonarrReleaseDownloadBody { + guid: "1234".to_owned(), + indexer_id: 2, + series_id: Some(1), + ..SonarrReleaseDownloadBody::default() + }; + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Post, + Some(json!({ + "guid": "1234", + "indexerId": 2, + "seriesId": 1 + })), + Some(json!({})), + None, + SonarrEvent::DownloadRelease(params.clone()), + None, + None, + ) + .await; + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + assert!(network + .handle_sonarr_event(SonarrEvent::DownloadRelease(params)) + .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) { From 8002a5aa1eb691c642719156034d8da0d84315c0 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Fri, 22 Nov 2024 19:53:54 -0700 Subject: [PATCH 086/119] feat(cli): Support for downloading a Series release in Sonarr --- src/cli/sonarr/download_command_handler.rs | 90 ++++++++++ .../sonarr/download_command_handler_tests.rs | 166 ++++++++++++++++++ src/cli/sonarr/mod.rs | 12 ++ src/cli/sonarr/sonarr_command_tests.rs | 44 ++++- 4 files changed, 309 insertions(+), 3 deletions(-) create mode 100644 src/cli/sonarr/download_command_handler.rs create mode 100644 src/cli/sonarr/download_command_handler_tests.rs diff --git a/src/cli/sonarr/download_command_handler.rs b/src/cli/sonarr/download_command_handler.rs new file mode 100644 index 0000000..3fa624f --- /dev/null +++ b/src/cli/sonarr/download_command_handler.rs @@ -0,0 +1,90 @@ +use std::sync::Arc; + +use clap::Subcommand; +use tokio::sync::Mutex; + +use crate::{ + app::App, + cli::{CliCommandHandler, Command}, + models::sonarr_models::SonarrReleaseDownloadBody, + network::{sonarr_network::SonarrEvent, NetworkTrait}, +}; + +use super::SonarrCommand; + +#[cfg(test)] +#[path = "download_command_handler_tests.rs"] +mod download_command_handler_tests; + +#[derive(Debug, Clone, PartialEq, Eq, Subcommand)] +pub enum SonarrDownloadCommand { + #[command(about = "Manually download the given series release for the specified series ID")] + Series { + #[arg(long, help = "The GUID of the release to download", required = true)] + guid: String, + #[arg( + long, + help = "The indexer ID to download the release from", + required = true + )] + indexer_id: i64, + #[arg( + long, + help = "The series ID that the release is associated with", + required = true + )] + series_id: i64, + }, +} + +impl From for Command { + fn from(value: SonarrDownloadCommand) -> Self { + Command::Sonarr(SonarrCommand::Download(value)) + } +} + +pub(super) struct SonarrDownloadCommandHandler<'a, 'b> { + _app: &'a Arc>>, + command: SonarrDownloadCommand, + network: &'a mut dyn NetworkTrait, +} + +impl<'a, 'b> CliCommandHandler<'a, 'b, SonarrDownloadCommand> + for SonarrDownloadCommandHandler<'a, 'b> +{ + fn with( + _app: &'a Arc>>, + command: SonarrDownloadCommand, + network: &'a mut dyn NetworkTrait, + ) -> Self { + SonarrDownloadCommandHandler { + _app, + command, + network, + } + } + + async fn handle(self) -> anyhow::Result { + let result = match self.command { + SonarrDownloadCommand::Series { + guid, + indexer_id, + series_id, + } => { + let params = SonarrReleaseDownloadBody { + guid, + indexer_id, + series_id: Some(series_id), + ..SonarrReleaseDownloadBody::default() + }; + let resp = self + .network + .handle_network_event(SonarrEvent::DownloadRelease(params).into()) + .await?; + serde_json::to_string_pretty(&resp)? + } + }; + + Ok(result) + } +} diff --git a/src/cli/sonarr/download_command_handler_tests.rs b/src/cli/sonarr/download_command_handler_tests.rs new file mode 100644 index 0000000..870b109 --- /dev/null +++ b/src/cli/sonarr/download_command_handler_tests.rs @@ -0,0 +1,166 @@ +#[cfg(test)] +mod tests { + use crate::{ + cli::{ + sonarr::{download_command_handler::SonarrDownloadCommand, SonarrCommand}, + Command, + }, + Cli, + }; + use clap::CommandFactory; + use pretty_assertions::assert_eq; + + #[test] + fn test_sonarr_download_command_from() { + let command = SonarrDownloadCommand::Series { + guid: "Test".to_owned(), + indexer_id: 1, + series_id: 1, + }; + + let result = Command::from(command.clone()); + + assert_eq!(result, Command::Sonarr(SonarrCommand::Download(command))); + } + + mod cli { + use super::*; + use clap::error::ErrorKind; + use pretty_assertions::assert_eq; + use rstest::rstest; + + #[rstest] + fn test_download_series_requires_series_id() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "sonarr", + "download", + "series", + "--indexer-id", + "1", + "--guid", + "1", + ]); + + assert!(result.is_err()); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[rstest] + fn test_download_series_requires_guid() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "sonarr", + "download", + "series", + "--indexer-id", + "1", + "--series-id", + "1", + ]); + + assert!(result.is_err()); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[rstest] + fn test_download_series_requires_indexer_id() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "sonarr", + "download", + "series", + "--guid", + "1", + "--series-id", + "1", + ]); + + assert!(result.is_err()); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_download_series_requirements_satisfied() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "sonarr", + "download", + "series", + "--guid", + "1", + "--series-id", + "1", + "--indexer-id", + "1", + ]); + + assert!(result.is_ok()); + } + } + + mod handler { + use std::sync::Arc; + + use mockall::predicate::eq; + use serde_json::json; + use tokio::sync::Mutex; + + use crate::{ + app::App, + cli::{ + sonarr::download_command_handler::{SonarrDownloadCommand, SonarrDownloadCommandHandler}, + CliCommandHandler, + }, + models::{ + sonarr_models::{SonarrReleaseDownloadBody, SonarrSerdeable}, + Serdeable, + }, + network::{sonarr_network::SonarrEvent, MockNetworkTrait, NetworkEvent}, + }; + + #[tokio::test] + async fn test_download_release_command() { + let expected_release_download_body = SonarrReleaseDownloadBody { + guid: "guid".to_owned(), + indexer_id: 1, + series_id: Some(1), + ..SonarrReleaseDownloadBody::default() + }; + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::( + SonarrEvent::DownloadRelease(expected_release_download_body).into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Sonarr(SonarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::default())); + let download_release_command = SonarrDownloadCommand::Series { + guid: "guid".to_owned(), + indexer_id: 1, + series_id: 1, + }; + + let result = + SonarrDownloadCommandHandler::with(&app_arc, download_release_command, &mut mock_network) + .handle() + .await; + + assert!(result.is_ok()); + } + } +} diff --git a/src/cli/sonarr/mod.rs b/src/cli/sonarr/mod.rs index 68b2b23..bc1fdcd 100644 --- a/src/cli/sonarr/mod.rs +++ b/src/cli/sonarr/mod.rs @@ -4,6 +4,7 @@ use add_command_handler::{SonarrAddCommand, SonarrAddCommandHandler}; use anyhow::Result; use clap::Subcommand; use delete_command_handler::{SonarrDeleteCommand, SonarrDeleteCommandHandler}; +use download_command_handler::{SonarrDownloadCommand, SonarrDownloadCommandHandler}; use get_command_handler::{SonarrGetCommand, SonarrGetCommandHandler}; use list_command_handler::{SonarrListCommand, SonarrListCommandHandler}; use refresh_command_handler::{SonarrRefreshCommand, SonarrRefreshCommandHandler}; @@ -19,6 +20,7 @@ use super::{CliCommandHandler, Command}; mod add_command_handler; mod delete_command_handler; +mod download_command_handler; mod get_command_handler; mod list_command_handler; mod refresh_command_handler; @@ -44,6 +46,11 @@ pub enum SonarrCommand { about = "Commands to fetch details of the resources in your Sonarr instance" )] Get(SonarrGetCommand), + #[command( + subcommand, + about = "Commands to download releases in your Sonarr instance" + )] + Download(SonarrDownloadCommand), #[command( subcommand, about = "Commands to list attributes from your Sonarr instance" @@ -176,6 +183,11 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, SonarrCommand> for SonarrCliHandler<'a, ' .handle() .await? } + SonarrCommand::Download(download_command) => { + SonarrDownloadCommandHandler::with(self.app, download_command, self.network) + .handle() + .await? + } SonarrCommand::Get(get_command) => { SonarrGetCommandHandler::with(self.app, get_command, self.network) .handle() diff --git a/src/cli/sonarr/sonarr_command_tests.rs b/src/cli/sonarr/sonarr_command_tests.rs index a5b580e..9a98496 100644 --- a/src/cli/sonarr/sonarr_command_tests.rs +++ b/src/cli/sonarr/sonarr_command_tests.rs @@ -311,14 +311,16 @@ mod tests { cli::{ sonarr::{ add_command_handler::SonarrAddCommand, delete_command_handler::SonarrDeleteCommand, - get_command_handler::SonarrGetCommand, list_command_handler::SonarrListCommand, - refresh_command_handler::SonarrRefreshCommand, SonarrCliHandler, SonarrCommand, + download_command_handler::SonarrDownloadCommand, get_command_handler::SonarrGetCommand, + list_command_handler::SonarrListCommand, refresh_command_handler::SonarrRefreshCommand, + SonarrCliHandler, SonarrCommand, }, CliCommandHandler, }, models::{ sonarr_models::{ - BlocklistItem, BlocklistResponse, Series, SonarrSerdeable, SonarrTaskName, + BlocklistItem, BlocklistResponse, Series, SonarrReleaseDownloadBody, SonarrSerdeable, + SonarrTaskName, }, Serdeable, }, @@ -500,6 +502,42 @@ mod tests { assert!(result.is_ok()); } + #[tokio::test] + async fn test_sonarr_cli_handler_delegates_download_commands_to_the_download_command_handler() { + let expected_params = SonarrReleaseDownloadBody { + guid: "1234".to_owned(), + indexer_id: 1, + series_id: Some(1), + ..SonarrReleaseDownloadBody::default() + }; + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::( + SonarrEvent::DownloadRelease(expected_params).into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Sonarr(SonarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::default())); + let download_series_release_command = + SonarrCommand::Download(SonarrDownloadCommand::Series { + guid: "1234".to_owned(), + indexer_id: 1, + series_id: 1, + }); + + let result = + SonarrCliHandler::with(&app_arc, download_series_release_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(); From 5ed3372ae2bcfc037eefb95355ccc36bf66f474f Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Fri, 22 Nov 2024 20:00:41 -0700 Subject: [PATCH 087/119] feat(cli): Support for downloading a season release in Sonarr --- src/cli/sonarr/download_command_handler.rs | 44 +++++ .../sonarr/download_command_handler_tests.rs | 154 +++++++++++++++++- 2 files changed, 193 insertions(+), 5 deletions(-) diff --git a/src/cli/sonarr/download_command_handler.rs b/src/cli/sonarr/download_command_handler.rs index 3fa624f..4338ee3 100644 --- a/src/cli/sonarr/download_command_handler.rs +++ b/src/cli/sonarr/download_command_handler.rs @@ -35,6 +35,31 @@ pub enum SonarrDownloadCommand { )] series_id: i64, }, + #[command( + about = "Manually download the given season release corresponding to the series specified with the series ID" + )] + Season { + #[arg(long, help = "The GUID of the release to download", required = true)] + guid: String, + #[arg( + long, + help = "The indexer ID to download the release from", + required = true + )] + indexer_id: i64, + #[arg( + long, + help = "The series ID that the release is associated with", + required = true + )] + series_id: i64, + #[arg( + long, + help = "The season number that the release corresponds to", + required = true + )] + season_number: i64, + }, } impl From for Command { @@ -83,6 +108,25 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, SonarrDownloadCommand> .await?; serde_json::to_string_pretty(&resp)? } + SonarrDownloadCommand::Season { + guid, + indexer_id, + series_id, + season_number, + } => { + let params = SonarrReleaseDownloadBody { + guid, + indexer_id, + series_id: Some(series_id), + season_number: Some(season_number), + ..SonarrReleaseDownloadBody::default() + }; + let resp = self + .network + .handle_network_event(SonarrEvent::DownloadRelease(params).into()) + .await?; + serde_json::to_string_pretty(&resp)? + } }; Ok(result) diff --git a/src/cli/sonarr/download_command_handler_tests.rs b/src/cli/sonarr/download_command_handler_tests.rs index 870b109..ae7a661 100644 --- a/src/cli/sonarr/download_command_handler_tests.rs +++ b/src/cli/sonarr/download_command_handler_tests.rs @@ -27,9 +27,8 @@ mod tests { use super::*; use clap::error::ErrorKind; use pretty_assertions::assert_eq; - use rstest::rstest; - #[rstest] + #[test] fn test_download_series_requires_series_id() { let result = Cli::command().try_get_matches_from([ "managarr", @@ -49,7 +48,7 @@ mod tests { ); } - #[rstest] + #[test] fn test_download_series_requires_guid() { let result = Cli::command().try_get_matches_from([ "managarr", @@ -69,7 +68,7 @@ mod tests { ); } - #[rstest] + #[test] fn test_download_series_requires_indexer_id() { let result = Cli::command().try_get_matches_from([ "managarr", @@ -106,6 +105,114 @@ mod tests { assert!(result.is_ok()); } + + #[test] + fn test_download_season_requires_series_id() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "sonarr", + "download", + "season", + "--indexer-id", + "1", + "--season-number", + "1", + "--guid", + "1", + ]); + + assert!(result.is_err()); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_download_season_requires_season_number() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "sonarr", + "download", + "season", + "--indexer-id", + "1", + "--series-id", + "1", + "--guid", + "1", + ]); + + assert!(result.is_err()); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_download_season_requires_guid() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "sonarr", + "download", + "season", + "--indexer-id", + "1", + "--season-number", + "1", + "--series-id", + "1", + ]); + + assert!(result.is_err()); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_download_season_requires_indexer_id() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "sonarr", + "download", + "season", + "--guid", + "1", + "--season-number", + "1", + "--series-id", + "1", + ]); + + assert!(result.is_err()); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_download_season_requirements_satisfied() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "sonarr", + "download", + "season", + "--guid", + "1", + "--series-id", + "1", + "--season-number", + "1", + "--indexer-id", + "1", + ]); + + assert!(result.is_ok()); + } } mod handler { @@ -129,7 +236,7 @@ mod tests { }; #[tokio::test] - async fn test_download_release_command() { + async fn test_download_series_release_command() { let expected_release_download_body = SonarrReleaseDownloadBody { guid: "guid".to_owned(), indexer_id: 1, @@ -162,5 +269,42 @@ mod tests { assert!(result.is_ok()); } + + #[tokio::test] + async fn test_download_season_release_command() { + let expected_release_download_body = SonarrReleaseDownloadBody { + guid: "guid".to_owned(), + indexer_id: 1, + series_id: Some(1), + season_number: Some(1), + ..SonarrReleaseDownloadBody::default() + }; + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::( + SonarrEvent::DownloadRelease(expected_release_download_body).into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Sonarr(SonarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::default())); + let download_release_command = SonarrDownloadCommand::Season { + guid: "guid".to_owned(), + indexer_id: 1, + series_id: 1, + season_number: 1, + }; + + let result = + SonarrDownloadCommandHandler::with(&app_arc, download_release_command, &mut mock_network) + .handle() + .await; + + assert!(result.is_ok()); + } } } From 1b5979c36cbb6db286f8b352e98a153e37daa792 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Fri, 22 Nov 2024 20:04:57 -0700 Subject: [PATCH 088/119] feat(cli): Support for downloading an episode release in Sonarr --- src/cli/sonarr/download_command_handler.rs | 34 ++++++ .../sonarr/download_command_handler_tests.rs | 113 ++++++++++++++++++ 2 files changed, 147 insertions(+) diff --git a/src/cli/sonarr/download_command_handler.rs b/src/cli/sonarr/download_command_handler.rs index 4338ee3..88055b8 100644 --- a/src/cli/sonarr/download_command_handler.rs +++ b/src/cli/sonarr/download_command_handler.rs @@ -60,6 +60,23 @@ pub enum SonarrDownloadCommand { )] season_number: i64, }, + #[command(about = "Manually download the given episode release for the specified episode ID")] + Episode { + #[arg(long, help = "The GUID of the release to download", required = true)] + guid: String, + #[arg( + long, + help = "The indexer ID to download the release from", + required = true + )] + indexer_id: i64, + #[arg( + long, + help = "The episode ID that the release is associated with", + required = true + )] + episode_id: i64, + }, } impl From for Command { @@ -127,6 +144,23 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, SonarrDownloadCommand> .await?; serde_json::to_string_pretty(&resp)? } + SonarrDownloadCommand::Episode { + guid, + indexer_id, + episode_id, + } => { + let params = SonarrReleaseDownloadBody { + guid, + indexer_id, + episode_id: Some(episode_id), + ..SonarrReleaseDownloadBody::default() + }; + let resp = self + .network + .handle_network_event(SonarrEvent::DownloadRelease(params).into()) + .await?; + serde_json::to_string_pretty(&resp)? + } }; Ok(result) diff --git a/src/cli/sonarr/download_command_handler_tests.rs b/src/cli/sonarr/download_command_handler_tests.rs index ae7a661..c1a617b 100644 --- a/src/cli/sonarr/download_command_handler_tests.rs +++ b/src/cli/sonarr/download_command_handler_tests.rs @@ -213,6 +213,84 @@ mod tests { assert!(result.is_ok()); } + + #[test] + fn test_download_episode_requires_episode_id() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "sonarr", + "download", + "episode", + "--indexer-id", + "1", + "--guid", + "1", + ]); + + assert!(result.is_err()); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_download_episode_requires_guid() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "sonarr", + "download", + "episode", + "--indexer-id", + "1", + "--episode-id", + "1", + ]); + + assert!(result.is_err()); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_download_episode_requires_indexer_id() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "sonarr", + "download", + "episode", + "--guid", + "1", + "--episode-id", + "1", + ]); + + assert!(result.is_err()); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_download_episode_requirements_satisfied() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "sonarr", + "download", + "episode", + "--guid", + "1", + "--episode-id", + "1", + "--indexer-id", + "1", + ]); + + assert!(result.is_ok()); + } } mod handler { @@ -306,5 +384,40 @@ mod tests { assert!(result.is_ok()); } + + #[tokio::test] + async fn test_download_episode_release_command() { + let expected_release_download_body = SonarrReleaseDownloadBody { + guid: "guid".to_owned(), + indexer_id: 1, + episode_id: Some(1), + ..SonarrReleaseDownloadBody::default() + }; + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::( + SonarrEvent::DownloadRelease(expected_release_download_body).into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Sonarr(SonarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::default())); + let download_release_command = SonarrDownloadCommand::Episode { + guid: "guid".to_owned(), + indexer_id: 1, + episode_id: 1, + }; + + let result = + SonarrDownloadCommandHandler::with(&app_arc, download_release_command, &mut mock_network) + .handle() + .await; + + assert!(result.is_ok()); + } } } From ffc00691cb7bf31e91300df6590da7d717c132bf Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Fri, 22 Nov 2024 20:22:52 -0700 Subject: [PATCH 089/119] refactor(cli): Moved the manual-season-search and manual-episode-search commands into their own dedicated handler so the commands can now be manual-search episode or manual-search season --- src/cli/sonarr/download_command_handler.rs | 3 +- .../sonarr/manual_search_command_handler.rs | 99 +++++++++ .../manual_search_command_handler_tests.rs | 189 ++++++++++++++++++ src/cli/sonarr/mod.rs | 56 +----- src/cli/sonarr/sonarr_command_tests.rs | 146 +------------- 5 files changed, 309 insertions(+), 184 deletions(-) create mode 100644 src/cli/sonarr/manual_search_command_handler.rs create mode 100644 src/cli/sonarr/manual_search_command_handler_tests.rs diff --git a/src/cli/sonarr/download_command_handler.rs b/src/cli/sonarr/download_command_handler.rs index 88055b8..f990ab2 100644 --- a/src/cli/sonarr/download_command_handler.rs +++ b/src/cli/sonarr/download_command_handler.rs @@ -1,5 +1,6 @@ use std::sync::Arc; +use anyhow::Result; use clap::Subcommand; use tokio::sync::Mutex; @@ -106,7 +107,7 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, SonarrDownloadCommand> } } - async fn handle(self) -> anyhow::Result { + async fn handle(self) -> Result { let result = match self.command { SonarrDownloadCommand::Series { guid, diff --git a/src/cli/sonarr/manual_search_command_handler.rs b/src/cli/sonarr/manual_search_command_handler.rs new file mode 100644 index 0000000..5164526 --- /dev/null +++ b/src/cli/sonarr/manual_search_command_handler.rs @@ -0,0 +1,99 @@ +use std::sync::Arc; + +use anyhow::Result; +use clap::Subcommand; +use tokio::sync::Mutex; + +use crate::{ + app::App, + cli::{CliCommandHandler, Command}, + network::{sonarr_network::SonarrEvent, NetworkTrait}, +}; + +use super::SonarrCommand; + +#[cfg(test)] +#[path = "manual_search_command_handler_tests.rs"] +mod manual_search_command_handler_tests; + +#[derive(Debug, Clone, PartialEq, Eq, Subcommand)] +pub enum SonarrManualSearchCommand { + #[command(about = "Trigger a manual search of releases for the episode with the given ID")] + Episode { + #[arg( + long, + help = "The Sonarr ID of the episode whose releases you wish to fetch and list", + required = true + )] + episode_id: i64, + }, + #[command( + about = "Trigger a manual search of releases for the given season corresponding to the series with the given ID" + )] + Season { + #[arg( + long, + help = "The Sonarr ID of the series whose releases you wish to fetch and list", + required = true + )] + series_id: i64, + #[arg(long, help = "The season number to search for", required = true)] + season_number: i64, + }, +} + +impl From for Command { + fn from(value: SonarrManualSearchCommand) -> Self { + Command::Sonarr(SonarrCommand::ManualSearch(value)) + } +} + +pub(super) struct SonarrManualSearchCommandHandler<'a, 'b> { + _app: &'a Arc>>, + command: SonarrManualSearchCommand, + network: &'a mut dyn NetworkTrait, +} + +impl<'a, 'b> CliCommandHandler<'a, 'b, SonarrManualSearchCommand> + for SonarrManualSearchCommandHandler<'a, 'b> +{ + fn with( + _app: &'a Arc>>, + command: SonarrManualSearchCommand, + network: &'a mut dyn NetworkTrait, + ) -> Self { + SonarrManualSearchCommandHandler { + _app, + command, + network, + } + } + + async fn handle(self) -> Result { + let result = match self.command { + SonarrManualSearchCommand::Episode { episode_id } => { + println!("Searching for episode releases. This may take a minute..."); + let resp = self + .network + .handle_network_event(SonarrEvent::GetEpisodeReleases(Some(episode_id)).into()) + .await?; + serde_json::to_string_pretty(&resp)? + } + SonarrManualSearchCommand::Season { + series_id, + season_number, + } => { + println!("Searching for season releases. This may take a minute..."); + let resp = self + .network + .handle_network_event( + SonarrEvent::GetSeasonReleases(Some((series_id, season_number))).into(), + ) + .await?; + serde_json::to_string_pretty(&resp)? + } + }; + + Ok(result) + } +} diff --git a/src/cli/sonarr/manual_search_command_handler_tests.rs b/src/cli/sonarr/manual_search_command_handler_tests.rs new file mode 100644 index 0000000..26dc760 --- /dev/null +++ b/src/cli/sonarr/manual_search_command_handler_tests.rs @@ -0,0 +1,189 @@ +#[cfg(test)] +mod tests { + use crate::cli::{ + sonarr::{manual_search_command_handler::SonarrManualSearchCommand, SonarrCommand}, + Command, + }; + use crate::Cli; + use clap::CommandFactory; + use pretty_assertions::assert_eq; + + #[test] + fn test_sonarr_manual_search_command_from() { + let command = SonarrManualSearchCommand::Episode { episode_id: 1 }; + + let result = Command::from(command.clone()); + + assert_eq!( + result, + Command::Sonarr(SonarrCommand::ManualSearch(command)) + ); + } + + mod cli { + use super::*; + use clap::error::ErrorKind; + use pretty_assertions::assert_eq; + use rstest::rstest; + + #[rstest] + fn test_manual_season_search_requires_series_id() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "sonarr", + "manual-search", + "season", + "--season-number", + "1", + ]); + + assert!(result.is_err()); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[rstest] + fn test_manual_season_search_requires_season_number() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "sonarr", + "manual-search", + "season", + "--series-id", + "1", + ]); + + assert!(result.is_err()); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_manual_season_search_requirements_satisfied() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "sonarr", + "manual-search", + "season", + "--series-id", + "1", + "--season-number", + "1", + ]); + + assert!(result.is_ok()); + } + + #[test] + fn test_manual_episode_search_requires_episode_id() { + let result = + Cli::command().try_get_matches_from(["managarr", "sonarr", "manual-search", "episode"]); + + assert!(result.is_err()); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_manual_episode_search_requirements_satisfied() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "sonarr", + "manual-search", + "episode", + "--episode-id", + "1", + ]); + + assert!(result.is_ok()); + } + } + + mod handler { + use std::sync::Arc; + + use mockall::predicate::eq; + use serde_json::json; + use tokio::sync::Mutex; + + use crate::{ + app::App, + cli::{ + sonarr::manual_search_command_handler::{ + SonarrManualSearchCommand, SonarrManualSearchCommandHandler, + }, + CliCommandHandler, + }, + models::{sonarr_models::SonarrSerdeable, Serdeable}, + network::{sonarr_network::SonarrEvent, MockNetworkTrait, NetworkEvent}, + }; + + #[tokio::test] + async fn test_manual_episode_search_command() { + let expected_episode_id = 1; + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::( + SonarrEvent::GetEpisodeReleases(Some(expected_episode_id)).into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Sonarr(SonarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::default())); + let manual_episode_search_command = SonarrManualSearchCommand::Episode { episode_id: 1 }; + + let result = SonarrManualSearchCommandHandler::with( + &app_arc, + manual_episode_search_command, + &mut mock_network, + ) + .handle() + .await; + + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_manual_season_search_command() { + let expected_series_id = 1; + let expected_season_number = 1; + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::( + SonarrEvent::GetSeasonReleases(Some((expected_series_id, expected_season_number))).into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Sonarr(SonarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::default())); + let manual_season_search_command = SonarrManualSearchCommand::Season { + series_id: 1, + season_number: 1, + }; + + let result = SonarrManualSearchCommandHandler::with( + &app_arc, + manual_season_search_command, + &mut mock_network, + ) + .handle() + .await; + + assert!(result.is_ok()); + } + } +} diff --git a/src/cli/sonarr/mod.rs b/src/cli/sonarr/mod.rs index bc1fdcd..8ed7ecc 100644 --- a/src/cli/sonarr/mod.rs +++ b/src/cli/sonarr/mod.rs @@ -7,6 +7,7 @@ use delete_command_handler::{SonarrDeleteCommand, SonarrDeleteCommandHandler}; use download_command_handler::{SonarrDownloadCommand, SonarrDownloadCommandHandler}; use get_command_handler::{SonarrGetCommand, SonarrGetCommandHandler}; use list_command_handler::{SonarrListCommand, SonarrListCommandHandler}; +use manual_search_command_handler::{SonarrManualSearchCommand, SonarrManualSearchCommandHandler}; use refresh_command_handler::{SonarrRefreshCommand, SonarrRefreshCommandHandler}; use tokio::sync::Mutex; @@ -23,6 +24,7 @@ mod delete_command_handler; mod download_command_handler; mod get_command_handler; mod list_command_handler; +mod manual_search_command_handler; mod refresh_command_handler; #[cfg(test)] @@ -61,6 +63,8 @@ pub enum SonarrCommand { about = "Commands to refresh the data in your Sonarr instance" )] Refresh(SonarrRefreshCommand), + #[command(subcommand, about = "Commands to manually search for releases")] + ManualSearch(SonarrManualSearchCommand), #[command(about = "Clear the blocklist")] ClearBlocklist, #[command(about = "Mark the Sonarr history item with the given ID as 'failed'")] @@ -72,28 +76,6 @@ pub enum SonarrCommand { )] history_item_id: i64, }, - #[command(about = "Trigger a manual search of releases for the episode with the given ID")] - ManualEpisodeSearch { - #[arg( - long, - help = "The Sonarr ID of the episode whose releases you wish to fetch and list", - required = true - )] - episode_id: i64, - }, - #[command( - about = "Trigger a manual search of releases for the given season corresponding to the series with the given ID" - )] - ManualSeasonSearch { - #[arg( - long, - help = "The Sonarr ID of the series whose releases you wish to fetch and list", - required = true - )] - series_id: i64, - #[arg(long, help = "The season number to search for", required = true)] - season_number: i64, - }, #[command(about = "Start the specified Sonarr task")] StartTask { #[arg( @@ -198,8 +180,13 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, SonarrCommand> for SonarrCliHandler<'a, ' .handle() .await? } - SonarrCommand::Refresh(update_command) => { - SonarrRefreshCommandHandler::with(self.app, update_command, self.network) + SonarrCommand::Refresh(refresh_command) => { + SonarrRefreshCommandHandler::with(self.app, refresh_command, self.network) + .handle() + .await? + } + SonarrCommand::ManualSearch(manual_search_command) => { + SonarrManualSearchCommandHandler::with(self.app, manual_search_command, self.network) .handle() .await? } @@ -221,27 +208,6 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, SonarrCommand> for SonarrCliHandler<'a, ' .await?; "Sonarr history item marked as 'failed'".to_owned() } - SonarrCommand::ManualEpisodeSearch { episode_id } => { - println!("Searching for episode releases. This may take a minute..."); - let resp = self - .network - .handle_network_event(SonarrEvent::GetEpisodeReleases(Some(episode_id)).into()) - .await?; - serde_json::to_string_pretty(&resp)? - } - SonarrCommand::ManualSeasonSearch { - series_id, - season_number, - } => { - println!("Searching for season releases. This may take a minute..."); - let resp = self - .network - .handle_network_event( - SonarrEvent::GetSeasonReleases(Some((series_id, season_number))).into(), - ) - .await?; - serde_json::to_string_pretty(&resp)? - } SonarrCommand::StartTask { task_name } => { let resp = self .network diff --git a/src/cli/sonarr/sonarr_command_tests.rs b/src/cli/sonarr/sonarr_command_tests.rs index 9a98496..ef68518 100644 --- a/src/cli/sonarr/sonarr_command_tests.rs +++ b/src/cli/sonarr/sonarr_command_tests.rs @@ -32,7 +32,7 @@ mod tests { assert!(result.is_ok()); } - #[rstest] + #[test] fn test_mark_history_item_as_failed_requires_history_item_id() { let result = Cli::command().try_get_matches_from(["managarr", "sonarr", "mark-history-item-as-failed"]); @@ -44,7 +44,7 @@ mod tests { ); } - #[rstest] + #[test] fn test_mark_history_item_as_failed_requirements_satisfied() { let result = Cli::command().try_get_matches_from([ "managarr", @@ -57,81 +57,7 @@ mod tests { assert!(result.is_ok()); } - #[rstest] - fn test_manual_season_search_requires_series_id() { - let result = Cli::command().try_get_matches_from([ - "managarr", - "sonarr", - "manual-season-search", - "--season-number", - "1", - ]); - - assert!(result.is_err()); - assert_eq!( - result.unwrap_err().kind(), - ErrorKind::MissingRequiredArgument - ); - } - - #[rstest] - fn test_manual_season_search_requires_season_number() { - let result = Cli::command().try_get_matches_from([ - "managarr", - "sonarr", - "manual-season-search", - "--series-id", - "1", - ]); - - assert!(result.is_err()); - assert_eq!( - result.unwrap_err().kind(), - ErrorKind::MissingRequiredArgument - ); - } - #[test] - fn test_manual_season_search_requirements_satisfied() { - let result = Cli::command().try_get_matches_from([ - "managarr", - "sonarr", - "manual-season-search", - "--series-id", - "1", - "--season-number", - "1", - ]); - - assert!(result.is_ok()); - } - - #[rstest] - fn test_manual_episode_search_requires_episode_id() { - let result = - Cli::command().try_get_matches_from(["managarr", "sonarr", "manual-episode-search"]); - - assert!(result.is_err()); - assert_eq!( - result.unwrap_err().kind(), - ErrorKind::MissingRequiredArgument - ); - } - - #[test] - fn test_manual_episode_search_requirements_satisfied() { - let result = Cli::command().try_get_matches_from([ - "managarr", - "sonarr", - "manual-episode-search", - "--episode-id", - "1", - ]); - - assert!(result.is_ok()); - } - - #[rstest] fn test_start_task_requires_task_name() { let result = Cli::command().try_get_matches_from(["managarr", "sonarr", "start-task"]); @@ -142,7 +68,7 @@ mod tests { ); } - #[rstest] + #[test] fn test_start_task_task_name_validation() { let result = Cli::command().try_get_matches_from([ "managarr", @@ -169,7 +95,7 @@ mod tests { assert!(result.is_ok()); } - #[rstest] + #[test] fn test_test_indexer_requires_indexer_id() { let result = Cli::command().try_get_matches_from(["managarr", "sonarr", "test-indexer"]); @@ -193,7 +119,7 @@ mod tests { assert!(result.is_ok()); } - #[rstest] + #[test] fn test_trigger_automatic_series_search_requires_series_id() { let result = Cli::command().try_get_matches_from([ "managarr", @@ -221,7 +147,7 @@ mod tests { assert!(result.is_ok()); } - #[rstest] + #[test] fn test_trigger_automatic_season_search_requires_series_id() { let result = Cli::command().try_get_matches_from([ "managarr", @@ -238,7 +164,7 @@ mod tests { ); } - #[rstest] + #[test] fn test_trigger_automatic_season_search_requires_season_number() { let result = Cli::command().try_get_matches_from([ "managarr", @@ -270,7 +196,7 @@ mod tests { assert!(result.is_ok()); } - #[rstest] + #[test] fn test_trigger_automatic_episode_search_requires_episode_id() { let result = Cli::command().try_get_matches_from([ "managarr", @@ -390,62 +316,6 @@ mod tests { assert!(result.is_ok()); } - #[tokio::test] - async fn test_manual_episode_search_command() { - let expected_episode_id = 1; - let mut mock_network = MockNetworkTrait::new(); - mock_network - .expect_handle_network_event() - .with(eq::( - SonarrEvent::GetEpisodeReleases(Some(expected_episode_id)).into(), - )) - .times(1) - .returning(|_| { - Ok(Serdeable::Sonarr(SonarrSerdeable::Value( - json!({"testResponse": "response"}), - ))) - }); - let app_arc = Arc::new(Mutex::new(App::default())); - let manual_episode_search_command = SonarrCommand::ManualEpisodeSearch { episode_id: 1 }; - - let result = - SonarrCliHandler::with(&app_arc, manual_episode_search_command, &mut mock_network) - .handle() - .await; - - assert!(result.is_ok()); - } - - #[tokio::test] - async fn test_manual_season_search_command() { - let expected_series_id = 1; - let expected_season_number = 1; - let mut mock_network = MockNetworkTrait::new(); - mock_network - .expect_handle_network_event() - .with(eq::( - SonarrEvent::GetSeasonReleases(Some((expected_series_id, expected_season_number))).into(), - )) - .times(1) - .returning(|_| { - Ok(Serdeable::Sonarr(SonarrSerdeable::Value( - json!({"testResponse": "response"}), - ))) - }); - let app_arc = Arc::new(Mutex::new(App::default())); - let manual_season_search_command = SonarrCommand::ManualSeasonSearch { - series_id: 1, - season_number: 1, - }; - - let result = - SonarrCliHandler::with(&app_arc, manual_season_search_command, &mut mock_network) - .handle() - .await; - - assert!(result.is_ok()); - } - #[tokio::test] async fn test_sonarr_cli_handler_delegates_add_commands_to_the_add_command_handler() { let expected_tag_name = "test".to_owned(); From 746064c4300dd9e9d4dcefe5025fd1db516a91fa Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Fri, 22 Nov 2024 20:26:34 -0700 Subject: [PATCH 090/119] refactor(cli): Added an additional delegation test to ensure manual-search commands are delegated to the manual-search command handler --- src/cli/sonarr/sonarr_command_tests.rs | 33 ++++++++++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) diff --git a/src/cli/sonarr/sonarr_command_tests.rs b/src/cli/sonarr/sonarr_command_tests.rs index ef68518..6a23746 100644 --- a/src/cli/sonarr/sonarr_command_tests.rs +++ b/src/cli/sonarr/sonarr_command_tests.rs @@ -238,8 +238,9 @@ mod tests { sonarr::{ add_command_handler::SonarrAddCommand, delete_command_handler::SonarrDeleteCommand, download_command_handler::SonarrDownloadCommand, get_command_handler::SonarrGetCommand, - list_command_handler::SonarrListCommand, refresh_command_handler::SonarrRefreshCommand, - SonarrCliHandler, SonarrCommand, + list_command_handler::SonarrListCommand, + manual_search_command_handler::SonarrManualSearchCommand, + refresh_command_handler::SonarrRefreshCommand, SonarrCliHandler, SonarrCommand, }, CliCommandHandler, }, @@ -408,6 +409,34 @@ mod tests { assert!(result.is_ok()); } + #[tokio::test] + async fn test_sonarr_cli_handler_delegates_manual_search_commands_to_the_manual_search_command_handler( + ) { + let expected_episode_id = 1; + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::( + SonarrEvent::GetEpisodeReleases(Some(expected_episode_id)).into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Sonarr(SonarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::default())); + let manual_episode_search_command = + SonarrCommand::ManualSearch(SonarrManualSearchCommand::Episode { episode_id: 1 }); + + let result = + SonarrCliHandler::with(&app_arc, manual_episode_search_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(); From 3be9321df61aa6489453339454bbd39fc5f44153 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Fri, 22 Nov 2024 20:42:34 -0700 Subject: [PATCH 091/119] refactor(cli): the trigger-automatic-search commands now all have their own dedicated subcommand to keep things cleaner. Now they look like 'trigger-automatic-search episode/series/season' and their corresponding flags --- src/cli/sonarr/mod.rs | 75 ++--- src/cli/sonarr/sonarr_command_tests.rs | 236 +++------------- ...rigger_automatic_search_command_handler.rs | 113 ++++++++ ..._automatic_search_command_handler_tests.rs | 259 ++++++++++++++++++ 4 files changed, 423 insertions(+), 260 deletions(-) create mode 100644 src/cli/sonarr/trigger_automatic_search_command_handler.rs create mode 100644 src/cli/sonarr/trigger_automatic_search_command_handler_tests.rs diff --git a/src/cli/sonarr/mod.rs b/src/cli/sonarr/mod.rs index 8ed7ecc..573697b 100644 --- a/src/cli/sonarr/mod.rs +++ b/src/cli/sonarr/mod.rs @@ -10,6 +10,9 @@ use list_command_handler::{SonarrListCommand, SonarrListCommandHandler}; use manual_search_command_handler::{SonarrManualSearchCommand, SonarrManualSearchCommandHandler}; use refresh_command_handler::{SonarrRefreshCommand, SonarrRefreshCommandHandler}; use tokio::sync::Mutex; +use trigger_automatic_search_command_handler::{ + SonarrTriggerAutomaticSearchCommand, SonarrTriggerAutomaticSearchCommandHandler, +}; use crate::{ app::App, @@ -26,6 +29,7 @@ mod get_command_handler; mod list_command_handler; mod manual_search_command_handler; mod refresh_command_handler; +mod trigger_automatic_search_command_handler; #[cfg(test)] #[path = "sonarr_command_tests.rs"] @@ -65,6 +69,11 @@ pub enum SonarrCommand { Refresh(SonarrRefreshCommand), #[command(subcommand, about = "Commands to manually search for releases")] ManualSearch(SonarrManualSearchCommand), + #[command( + subcommand, + about = "Commands to trigger automatic searches for releases of different resources in your Sonarr instance" + )] + TriggerAutomaticSearch(SonarrTriggerAutomaticSearchCommand), #[command(about = "Clear the blocklist")] ClearBlocklist, #[command(about = "Mark the Sonarr history item with the given ID as 'failed'")] @@ -95,37 +104,6 @@ pub enum SonarrCommand { }, #[command(about = "Test all Radarr indexers")] TestAllIndexers, - #[command(about = "Trigger an automatic search for the series with the specified ID")] - TriggerAutomaticSeriesSearch { - #[arg( - long, - help = "The ID of the series you want to trigger an automatic search for", - required = true - )] - series_id: i64, - }, - #[command( - about = "Trigger an automatic search for the given season corresponding to the series with the given ID" - )] - TriggerAutomaticSeasonSearch { - #[arg( - long, - help = "The Sonarr ID of the series whose season you wish to trigger an automatic search for", - required = true - )] - series_id: i64, - #[arg(long, help = "The season number to search for", required = true)] - season_number: i64, - }, - #[command(about = "Trigger an automatic search for the episode with the specified ID")] - TriggerAutomaticEpisodeSearch { - #[arg( - long, - help = "The ID of the episode you want to trigger an automatic search for", - required = true - )] - episode_id: i64, - }, } impl From for Command { @@ -190,6 +168,15 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, SonarrCommand> for SonarrCliHandler<'a, ' .handle() .await? } + SonarrCommand::TriggerAutomaticSearch(trigger_automatic_search_command) => { + SonarrTriggerAutomaticSearchCommandHandler::with( + self.app, + trigger_automatic_search_command, + self.network, + ) + .handle() + .await? + } SonarrCommand::ClearBlocklist => { self .network @@ -230,32 +217,6 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, SonarrCommand> for SonarrCliHandler<'a, ' .await?; serde_json::to_string_pretty(&resp)? } - SonarrCommand::TriggerAutomaticSeriesSearch { series_id } => { - let resp = self - .network - .handle_network_event(SonarrEvent::TriggerAutomaticSeriesSearch(Some(series_id)).into()) - .await?; - serde_json::to_string_pretty(&resp)? - } - SonarrCommand::TriggerAutomaticSeasonSearch { - series_id, - season_number, - } => { - let resp = self - .network - .handle_network_event( - SonarrEvent::TriggerAutomaticSeasonSearch(Some((series_id, season_number))).into(), - ) - .await?; - serde_json::to_string_pretty(&resp)? - } - SonarrCommand::TriggerAutomaticEpisodeSearch { episode_id } => { - let resp = self - .network - .handle_network_event(SonarrEvent::TriggerAutomaticEpisodeSearch(Some(episode_id)).into()) - .await?; - serde_json::to_string_pretty(&resp)? - } }; Ok(result) diff --git a/src/cli/sonarr/sonarr_command_tests.rs b/src/cli/sonarr/sonarr_command_tests.rs index 6a23746..822aa69 100644 --- a/src/cli/sonarr/sonarr_command_tests.rs +++ b/src/cli/sonarr/sonarr_command_tests.rs @@ -118,111 +118,6 @@ mod tests { assert!(result.is_ok()); } - - #[test] - fn test_trigger_automatic_series_search_requires_series_id() { - let result = Cli::command().try_get_matches_from([ - "managarr", - "sonarr", - "trigger-automatic-series-search", - ]); - - assert!(result.is_err()); - assert_eq!( - result.unwrap_err().kind(), - ErrorKind::MissingRequiredArgument - ); - } - - #[test] - fn test_trigger_automatic_series_search_requirements_satisfied() { - let result = Cli::command().try_get_matches_from([ - "managarr", - "sonarr", - "trigger-automatic-series-search", - "--series-id", - "1", - ]); - - assert!(result.is_ok()); - } - - #[test] - fn test_trigger_automatic_season_search_requires_series_id() { - let result = Cli::command().try_get_matches_from([ - "managarr", - "sonarr", - "trigger-automatic-season-search", - "--season-number", - "1", - ]); - - assert!(result.is_err()); - assert_eq!( - result.unwrap_err().kind(), - ErrorKind::MissingRequiredArgument - ); - } - - #[test] - fn test_trigger_automatic_season_search_requires_season_number() { - let result = Cli::command().try_get_matches_from([ - "managarr", - "sonarr", - "trigger-automatic-season-search", - "--series-id", - "1", - ]); - - assert!(result.is_err()); - assert_eq!( - result.unwrap_err().kind(), - ErrorKind::MissingRequiredArgument - ); - } - - #[test] - fn test_trigger_automatic_season_search_requirements_satisfied() { - let result = Cli::command().try_get_matches_from([ - "managarr", - "sonarr", - "trigger-automatic-season-search", - "--series-id", - "1", - "--season-number", - "1", - ]); - - assert!(result.is_ok()); - } - - #[test] - fn test_trigger_automatic_episode_search_requires_episode_id() { - let result = Cli::command().try_get_matches_from([ - "managarr", - "sonarr", - "trigger-automatic-episode-search", - ]); - - assert!(result.is_err()); - assert_eq!( - result.unwrap_err().kind(), - ErrorKind::MissingRequiredArgument - ); - } - - #[test] - fn test_trigger_automatic_episode_search_requirements_satisfied() { - let result = Cli::command().try_get_matches_from([ - "managarr", - "sonarr", - "trigger-automatic-episode-search", - "--episode-id", - "1", - ]); - - assert!(result.is_ok()); - } } mod handler { @@ -240,7 +135,9 @@ mod tests { download_command_handler::SonarrDownloadCommand, get_command_handler::SonarrGetCommand, list_command_handler::SonarrListCommand, manual_search_command_handler::SonarrManualSearchCommand, - refresh_command_handler::SonarrRefreshCommand, SonarrCliHandler, SonarrCommand, + refresh_command_handler::SonarrRefreshCommand, + trigger_automatic_search_command_handler::SonarrTriggerAutomaticSearchCommand, + SonarrCliHandler, SonarrCommand, }, CliCommandHandler, }, @@ -437,6 +334,36 @@ mod tests { assert!(result.is_ok()); } + #[tokio::test] + async fn test_sonarr_cli_handler_delegates_trigger_automatic_search_commands_to_the_trigger_automatic_search_command_handler( + ) { + let expected_episode_id = 1; + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::( + SonarrEvent::TriggerAutomaticEpisodeSearch(Some(expected_episode_id)).into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Sonarr(SonarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::default())); + let manual_episode_search_command = + SonarrCommand::TriggerAutomaticSearch(SonarrTriggerAutomaticSearchCommand::Episode { + episode_id: 1, + }); + + let result = + SonarrCliHandler::with(&app_arc, manual_episode_search_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(); @@ -580,102 +507,5 @@ mod tests { assert!(result.is_ok()); } - - #[tokio::test] - async fn test_trigger_automatic_series_search_command() { - let expected_series_id = 1; - let mut mock_network = MockNetworkTrait::new(); - mock_network - .expect_handle_network_event() - .with(eq::( - SonarrEvent::TriggerAutomaticSeriesSearch(Some(expected_series_id)).into(), - )) - .times(1) - .returning(|_| { - Ok(Serdeable::Sonarr(SonarrSerdeable::Value( - json!({"testResponse": "response"}), - ))) - }); - let app_arc = Arc::new(Mutex::new(App::default())); - let trigger_automatic_series_search_command = - SonarrCommand::TriggerAutomaticSeriesSearch { series_id: 1 }; - - let result = SonarrCliHandler::with( - &app_arc, - trigger_automatic_series_search_command, - &mut mock_network, - ) - .handle() - .await; - - assert!(result.is_ok()); - } - - #[tokio::test] - async fn test_trigger_automatic_season_search_command() { - let expected_series_id = 1; - let expected_season_number = 1; - let mut mock_network = MockNetworkTrait::new(); - mock_network - .expect_handle_network_event() - .with(eq::( - SonarrEvent::TriggerAutomaticSeasonSearch(Some(( - expected_series_id, - expected_season_number, - ))) - .into(), - )) - .times(1) - .returning(|_| { - Ok(Serdeable::Sonarr(SonarrSerdeable::Value( - json!({"testResponse": "response"}), - ))) - }); - let app_arc = Arc::new(Mutex::new(App::default())); - let trigger_automatic_season_search_command = SonarrCommand::TriggerAutomaticSeasonSearch { - series_id: 1, - season_number: 1, - }; - - let result = SonarrCliHandler::with( - &app_arc, - trigger_automatic_season_search_command, - &mut mock_network, - ) - .handle() - .await; - - assert!(result.is_ok()); - } - - #[tokio::test] - async fn test_trigger_automatic_episode_search_command() { - let expected_episode_id = 1; - let mut mock_network = MockNetworkTrait::new(); - mock_network - .expect_handle_network_event() - .with(eq::( - SonarrEvent::TriggerAutomaticEpisodeSearch(Some(expected_episode_id)).into(), - )) - .times(1) - .returning(|_| { - Ok(Serdeable::Sonarr(SonarrSerdeable::Value( - json!({"testResponse": "response"}), - ))) - }); - let app_arc = Arc::new(Mutex::new(App::default())); - let trigger_automatic_episode_search_command = - SonarrCommand::TriggerAutomaticEpisodeSearch { episode_id: 1 }; - - let result = SonarrCliHandler::with( - &app_arc, - trigger_automatic_episode_search_command, - &mut mock_network, - ) - .handle() - .await; - - assert!(result.is_ok()); - } } } diff --git a/src/cli/sonarr/trigger_automatic_search_command_handler.rs b/src/cli/sonarr/trigger_automatic_search_command_handler.rs new file mode 100644 index 0000000..e87a5a5 --- /dev/null +++ b/src/cli/sonarr/trigger_automatic_search_command_handler.rs @@ -0,0 +1,113 @@ +use std::sync::Arc; + +use anyhow::Result; +use clap::Subcommand; +use tokio::sync::Mutex; + +use crate::{ + app::App, + cli::{CliCommandHandler, Command}, + network::{sonarr_network::SonarrEvent, NetworkTrait}, +}; + +use super::SonarrCommand; + +#[cfg(test)] +#[path = "trigger_automatic_search_command_handler_tests.rs"] +mod trigger_automatic_search_command_handler_tests; + +#[derive(Debug, Clone, PartialEq, Eq, Subcommand)] +pub enum SonarrTriggerAutomaticSearchCommand { + #[command(about = "Trigger an automatic search for the series with the specified ID")] + Series { + #[arg( + long, + help = "The ID of the series you want to trigger an automatic search for", + required = true + )] + series_id: i64, + }, + #[command( + about = "Trigger an automatic search for the given season corresponding to the series with the given ID" + )] + Season { + #[arg( + long, + help = "The Sonarr ID of the series whose season you wish to trigger an automatic search for", + required = true + )] + series_id: i64, + #[arg(long, help = "The season number to search for", required = true)] + season_number: i64, + }, + #[command(about = "Trigger an automatic search for the episode with the specified ID")] + Episode { + #[arg( + long, + help = "The ID of the episode you want to trigger an automatic search for", + required = true + )] + episode_id: i64, + }, +} + +impl From for Command { + fn from(value: SonarrTriggerAutomaticSearchCommand) -> Self { + Command::Sonarr(SonarrCommand::TriggerAutomaticSearch(value)) + } +} + +pub(super) struct SonarrTriggerAutomaticSearchCommandHandler<'a, 'b> { + _app: &'a Arc>>, + command: SonarrTriggerAutomaticSearchCommand, + network: &'a mut dyn NetworkTrait, +} + +impl<'a, 'b> CliCommandHandler<'a, 'b, SonarrTriggerAutomaticSearchCommand> + for SonarrTriggerAutomaticSearchCommandHandler<'a, 'b> +{ + fn with( + _app: &'a Arc>>, + command: SonarrTriggerAutomaticSearchCommand, + network: &'a mut dyn NetworkTrait, + ) -> Self { + SonarrTriggerAutomaticSearchCommandHandler { + _app, + command, + network, + } + } + + async fn handle(self) -> Result { + let result = match self.command { + SonarrTriggerAutomaticSearchCommand::Series { series_id } => { + let resp = self + .network + .handle_network_event(SonarrEvent::TriggerAutomaticSeriesSearch(Some(series_id)).into()) + .await?; + serde_json::to_string_pretty(&resp)? + } + SonarrTriggerAutomaticSearchCommand::Season { + series_id, + season_number, + } => { + let resp = self + .network + .handle_network_event( + SonarrEvent::TriggerAutomaticSeasonSearch(Some((series_id, season_number))).into(), + ) + .await?; + serde_json::to_string_pretty(&resp)? + } + SonarrTriggerAutomaticSearchCommand::Episode { episode_id } => { + let resp = self + .network + .handle_network_event(SonarrEvent::TriggerAutomaticEpisodeSearch(Some(episode_id)).into()) + .await?; + serde_json::to_string_pretty(&resp)? + } + }; + + Ok(result) + } +} diff --git a/src/cli/sonarr/trigger_automatic_search_command_handler_tests.rs b/src/cli/sonarr/trigger_automatic_search_command_handler_tests.rs new file mode 100644 index 0000000..03c4057 --- /dev/null +++ b/src/cli/sonarr/trigger_automatic_search_command_handler_tests.rs @@ -0,0 +1,259 @@ +#[cfg(test)] +mod tests { + use crate::cli::{ + sonarr::{ + trigger_automatic_search_command_handler::SonarrTriggerAutomaticSearchCommand, SonarrCommand, + }, + Command, + }; + use crate::Cli; + use clap::CommandFactory; + use pretty_assertions::assert_eq; + + #[test] + fn test_sonarr_trigger_automatic_search_command_from() { + let command = SonarrTriggerAutomaticSearchCommand::Episode { episode_id: 1 }; + + let result = Command::from(command.clone()); + + assert_eq!( + result, + Command::Sonarr(SonarrCommand::TriggerAutomaticSearch(command)) + ); + } + + mod cli { + use super::*; + use clap::error::ErrorKind; + use pretty_assertions::assert_eq; + + #[test] + fn test_trigger_automatic_series_search_requires_series_id() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "sonarr", + "trigger-automatic-search", + "series", + ]); + + assert!(result.is_err()); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_trigger_automatic_series_search_requirements_satisfied() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "sonarr", + "trigger-automatic-search", + "series", + "--series-id", + "1", + ]); + + assert!(result.is_ok()); + } + + #[test] + fn test_trigger_automatic_season_search_requires_series_id() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "sonarr", + "trigger-automatic-search", + "season", + "--season-number", + "1", + ]); + + assert!(result.is_err()); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_trigger_automatic_season_search_requires_season_number() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "sonarr", + "trigger-automatic-search", + "season", + "--series-id", + "1", + ]); + + assert!(result.is_err()); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_trigger_automatic_season_search_requirements_satisfied() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "sonarr", + "trigger-automatic-search", + "season", + "--series-id", + "1", + "--season-number", + "1", + ]); + + assert!(result.is_ok()); + } + + #[test] + fn test_trigger_automatic_episode_search_requires_episode_id() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "sonarr", + "trigger-automatic-search", + "episode", + ]); + + assert!(result.is_err()); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_trigger_automatic_episode_search_requirements_satisfied() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "sonarr", + "trigger-automatic-search", + "episode", + "--episode-id", + "1", + ]); + + assert!(result.is_ok()); + } + } + + mod handler { + use std::sync::Arc; + + use mockall::predicate::eq; + use serde_json::json; + use tokio::sync::Mutex; + + use crate::{ + app::App, + cli::{ + sonarr::trigger_automatic_search_command_handler::{ + SonarrTriggerAutomaticSearchCommand, SonarrTriggerAutomaticSearchCommandHandler, + }, + CliCommandHandler, + }, + models::{sonarr_models::SonarrSerdeable, Serdeable}, + network::{sonarr_network::SonarrEvent, MockNetworkTrait, NetworkEvent}, + }; + + #[tokio::test] + async fn test_trigger_automatic_series_search_command() { + let expected_series_id = 1; + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::( + SonarrEvent::TriggerAutomaticSeriesSearch(Some(expected_series_id)).into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Sonarr(SonarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::default())); + let trigger_automatic_series_search_command = + SonarrTriggerAutomaticSearchCommand::Series { series_id: 1 }; + + let result = SonarrTriggerAutomaticSearchCommandHandler::with( + &app_arc, + trigger_automatic_series_search_command, + &mut mock_network, + ) + .handle() + .await; + + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_trigger_automatic_season_search_command() { + let expected_series_id = 1; + let expected_season_number = 1; + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::( + SonarrEvent::TriggerAutomaticSeasonSearch(Some(( + expected_series_id, + expected_season_number, + ))) + .into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Sonarr(SonarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::default())); + let trigger_automatic_season_search_command = SonarrTriggerAutomaticSearchCommand::Season { + series_id: 1, + season_number: 1, + }; + + let result = SonarrTriggerAutomaticSearchCommandHandler::with( + &app_arc, + trigger_automatic_season_search_command, + &mut mock_network, + ) + .handle() + .await; + + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_trigger_automatic_episode_search_command() { + let expected_episode_id = 1; + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::( + SonarrEvent::TriggerAutomaticEpisodeSearch(Some(expected_episode_id)).into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Sonarr(SonarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::default())); + let trigger_automatic_episode_search_command = + SonarrTriggerAutomaticSearchCommand::Episode { episode_id: 1 }; + + let result = SonarrTriggerAutomaticSearchCommandHandler::with( + &app_arc, + trigger_automatic_episode_search_command, + &mut mock_network, + ) + .handle() + .await; + + assert!(result.is_ok()); + } + } +} From 4d92c350dec92179599406d027ba0d5cdbf10640 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Sat, 23 Nov 2024 12:15:41 -0700 Subject: [PATCH 092/119] fix(network): Added filtering for full seasons specifically in the UI when performing a manual full season search and added a message to the CLI that noes to only try to download a full season if that release includes 'fullSeason: true' --- src/app/radarr/radarr_tests.rs | 5 +- .../sonarr/manual_search_command_handler.rs | 2 +- .../library/movie_details_handler.rs | 5 +- .../library/movie_details_handler_tests.rs | 75 +++-- src/models/radarr_models.rs | 28 +- src/models/radarr_models_tests.rs | 8 +- src/models/servarr_data/radarr/modals.rs | 6 +- .../servarr_data/radarr/radarr_test_utils.rs | 5 +- src/models/servarr_data/sonarr/modals.rs | 7 +- src/models/servarr_models.rs | 22 -- src/models/sonarr_models.rs | 28 +- src/models/sonarr_models_tests.rs | 10 +- src/network/radarr_network.rs | 12 +- src/network/radarr_network_tests.rs | 4 +- src/network/sonarr_network.rs | 21 +- src/network/sonarr_network_tests.rs | 263 +++++++++++++----- src/ui/radarr_ui/library/movie_details_ui.rs | 9 +- 17 files changed, 336 insertions(+), 174 deletions(-) diff --git a/src/app/radarr/radarr_tests.rs b/src/app/radarr/radarr_tests.rs index e4be8f1..901ca12 100644 --- a/src/app/radarr/radarr_tests.rs +++ b/src/app/radarr/radarr_tests.rs @@ -6,10 +6,9 @@ mod tests { use crate::app::radarr::ActiveRadarrBlock; use crate::app::App; - use crate::models::radarr_models::{Collection, CollectionMovie, Credit}; + use crate::models::radarr_models::{Collection, CollectionMovie, Credit, RadarrRelease}; use crate::models::servarr_data::radarr::modals::MovieDetailsModal; - use crate::models::servarr_models::Release; use crate::network::radarr_network::RadarrEvent; use crate::network::NetworkEvent; @@ -431,7 +430,7 @@ mod tests { let mut movie_details_modal = MovieDetailsModal::default(); movie_details_modal .movie_releases - .set_items(vec![Release::default()]); + .set_items(vec![RadarrRelease::default()]); app.data.radarr_data.movie_details_modal = Some(movie_details_modal); app diff --git a/src/cli/sonarr/manual_search_command_handler.rs b/src/cli/sonarr/manual_search_command_handler.rs index 5164526..e8e1ca3 100644 --- a/src/cli/sonarr/manual_search_command_handler.rs +++ b/src/cli/sonarr/manual_search_command_handler.rs @@ -28,7 +28,7 @@ pub enum SonarrManualSearchCommand { episode_id: i64, }, #[command( - about = "Trigger a manual search of releases for the given season corresponding to the series with the given ID" + about = "Trigger a manual search of releases for the given season corresponding to the series with the given ID.\nNote that when downloading a season release, ensure that the release includes 'fullSeason: true', otherwise you'll run into issues" )] Season { #[arg( diff --git a/src/handlers/radarr_handlers/library/movie_details_handler.rs b/src/handlers/radarr_handlers/library/movie_details_handler.rs index fce5b46..501f304 100644 --- a/src/handlers/radarr_handlers/library/movie_details_handler.rs +++ b/src/handlers/radarr_handlers/library/movie_details_handler.rs @@ -4,10 +4,11 @@ use crate::app::key_binding::DEFAULT_KEYBINDINGS; use crate::app::App; use crate::event::Key; use crate::handlers::{handle_prompt_toggle, KeyEventHandler}; +use crate::models::radarr_models::RadarrRelease; use crate::models::servarr_data::radarr::radarr_data::{ ActiveRadarrBlock, EDIT_MOVIE_SELECTION_BLOCKS, MOVIE_DETAILS_BLOCKS, }; -use crate::models::servarr_models::{Language, Release}; +use crate::models::servarr_models::Language; use crate::models::stateful_table::SortOption; use crate::models::{BlockSelectionState, Scrollable}; use crate::network::radarr_network::RadarrEvent; @@ -505,7 +506,7 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for MovieDetailsHandler< } } -fn releases_sorting_options() -> Vec> { +fn releases_sorting_options() -> Vec> { vec![ SortOption { name: "Source", diff --git a/src/handlers/radarr_handlers/library/movie_details_handler_tests.rs b/src/handlers/radarr_handlers/library/movie_details_handler_tests.rs index 174232b..fef7f73 100644 --- a/src/handlers/radarr_handlers/library/movie_details_handler_tests.rs +++ b/src/handlers/radarr_handlers/library/movie_details_handler_tests.rs @@ -14,10 +14,11 @@ mod tests { releases_sorting_options, MovieDetailsHandler, }; use crate::handlers::KeyEventHandler; + use crate::models::radarr_models::RadarrRelease; use crate::models::radarr_models::{Credit, MovieHistoryItem}; use crate::models::servarr_data::radarr::modals::MovieDetailsModal; use crate::models::servarr_data::radarr::radarr_data::{ActiveRadarrBlock, MOVIE_DETAILS_BLOCKS}; - use crate::models::servarr_models::{Language, Quality, QualityWrapper, Release}; + use crate::models::servarr_models::{Language, Quality, QualityWrapper}; use crate::models::stateful_table::SortOption; use crate::models::{HorizontallyScrollableText, ScrollableText}; @@ -405,7 +406,7 @@ mod tests { movie_details_modal .movie_releases .set_items(simple_stateful_iterable_vec!( - Release, + RadarrRelease, HorizontallyScrollableText )); app.data.radarr_data.movie_details_modal = Some(movie_details_modal); @@ -453,7 +454,7 @@ mod tests { movie_details_modal .movie_releases .set_items(simple_stateful_iterable_vec!( - Release, + RadarrRelease, HorizontallyScrollableText )); app.data.radarr_data.movie_details_modal = Some(movie_details_modal); @@ -996,7 +997,7 @@ mod tests { movie_details_modal .movie_releases .set_items(extended_stateful_iterable_vec!( - Release, + RadarrRelease, HorizontallyScrollableText )); app.data.radarr_data.movie_details_modal = Some(movie_details_modal); @@ -1054,7 +1055,7 @@ mod tests { movie_details_modal .movie_releases .set_items(extended_stateful_iterable_vec!( - Release, + RadarrRelease, HorizontallyScrollableText )); app.data.radarr_data.movie_details_modal = Some(movie_details_modal); @@ -1249,7 +1250,9 @@ mod tests { movie_details: ScrollableText::with_string("test".to_owned()), ..MovieDetailsModal::default() }; - modal.movie_releases.set_items(vec![Release::default()]); + modal + .movie_releases + .set_items(vec![RadarrRelease::default()]); app.data.radarr_data.movie_details_modal = Some(modal); app.push_navigation_stack(ActiveRadarrBlock::ManualSearch.into()); @@ -1487,6 +1490,8 @@ mod tests { )] active_radarr_block: ActiveRadarrBlock, ) { + use crate::models::radarr_models::RadarrRelease; + let mut app = App::default(); let mut modal = MovieDetailsModal { movie_details: ScrollableText::with_string("Test".to_owned()), @@ -1497,7 +1502,9 @@ mod tests { .set_items(vec![MovieHistoryItem::default()]); modal.movie_cast.set_items(vec![Credit::default()]); modal.movie_crew.set_items(vec![Credit::default()]); - modal.movie_releases.set_items(vec![Release::default()]); + modal + .movie_releases + .set_items(vec![RadarrRelease::default()]); app.data.radarr_data.movie_details_modal = Some(modal); MovieDetailsHandler::with( @@ -1687,7 +1694,9 @@ mod tests { .set_items(vec![MovieHistoryItem::default()]); modal.movie_cast.set_items(vec![Credit::default()]); modal.movie_crew.set_items(vec![Credit::default()]); - modal.movie_releases.set_items(vec![Release::default()]); + modal + .movie_releases + .set_items(vec![RadarrRelease::default()]); app.data.radarr_data.movie_details_modal = Some(modal); MovieDetailsHandler::with( @@ -1757,7 +1766,9 @@ mod tests { .set_items(vec![MovieHistoryItem::default()]); modal.movie_cast.set_items(vec![Credit::default()]); modal.movie_crew.set_items(vec![Credit::default()]); - modal.movie_releases.set_items(vec![Release::default()]); + modal + .movie_releases + .set_items(vec![RadarrRelease::default()]); app.data.radarr_data.movie_details_modal = Some(modal); MovieDetailsHandler::with( @@ -1851,7 +1862,8 @@ mod tests { #[test] fn test_releases_sorting_options_source() { - let expected_cmp_fn: fn(&Release, &Release) -> Ordering = |a, b| a.protocol.cmp(&b.protocol); + let expected_cmp_fn: fn(&RadarrRelease, &RadarrRelease) -> Ordering = + |a, b| a.protocol.cmp(&b.protocol); let mut expected_releases_vec = release_vec(); expected_releases_vec.sort_by(expected_cmp_fn); @@ -1865,7 +1877,7 @@ mod tests { #[test] fn test_releases_sorting_options_age() { - let expected_cmp_fn: fn(&Release, &Release) -> Ordering = |a, b| a.age.cmp(&b.age); + let expected_cmp_fn: fn(&RadarrRelease, &RadarrRelease) -> Ordering = |a, b| a.age.cmp(&b.age); let mut expected_releases_vec = release_vec(); expected_releases_vec.sort_by(expected_cmp_fn); @@ -1879,7 +1891,8 @@ mod tests { #[test] fn test_releases_sorting_options_rejected() { - let expected_cmp_fn: fn(&Release, &Release) -> Ordering = |a, b| a.rejected.cmp(&b.rejected); + let expected_cmp_fn: fn(&RadarrRelease, &RadarrRelease) -> Ordering = + |a, b| a.rejected.cmp(&b.rejected); let mut expected_releases_vec = release_vec(); expected_releases_vec.sort_by(expected_cmp_fn); @@ -1893,7 +1906,7 @@ mod tests { #[test] fn test_releases_sorting_options_title() { - let expected_cmp_fn: fn(&Release, &Release) -> Ordering = |a, b| { + let expected_cmp_fn: fn(&RadarrRelease, &RadarrRelease) -> Ordering = |a, b| { a.title .text .to_lowercase() @@ -1912,7 +1925,7 @@ mod tests { #[test] fn test_releases_sorting_options_indexer() { - let expected_cmp_fn: fn(&Release, &Release) -> Ordering = + let expected_cmp_fn: fn(&RadarrRelease, &RadarrRelease) -> Ordering = |a, b| a.indexer.to_lowercase().cmp(&b.indexer.to_lowercase()); let mut expected_releases_vec = release_vec(); expected_releases_vec.sort_by(expected_cmp_fn); @@ -1927,7 +1940,8 @@ mod tests { #[test] fn test_releases_sorting_options_size() { - let expected_cmp_fn: fn(&Release, &Release) -> Ordering = |a, b| a.size.cmp(&b.size); + let expected_cmp_fn: fn(&RadarrRelease, &RadarrRelease) -> Ordering = + |a, b| a.size.cmp(&b.size); let mut expected_releases_vec = release_vec(); expected_releases_vec.sort_by(expected_cmp_fn); @@ -1941,7 +1955,7 @@ mod tests { #[test] fn test_releases_sorting_options_peers() { - let expected_cmp_fn: fn(&Release, &Release) -> Ordering = |a, b| { + let expected_cmp_fn: fn(&RadarrRelease, &RadarrRelease) -> Ordering = |a, b| { let default_number = Number::from(i64::MAX); let seeder_a = a .seeders @@ -1971,7 +1985,7 @@ mod tests { #[test] fn test_releases_sorting_options_language() { - let expected_cmp_fn: fn(&Release, &Release) -> Ordering = |a, b| { + let expected_cmp_fn: fn(&RadarrRelease, &RadarrRelease) -> Ordering = |a, b| { let default_language_vec = vec![Language { name: "_".to_owned(), }]; @@ -1993,7 +2007,8 @@ mod tests { #[test] fn test_releases_sorting_options_quality() { - let expected_cmp_fn: fn(&Release, &Release) -> Ordering = |a, b| a.quality.cmp(&b.quality); + let expected_cmp_fn: fn(&RadarrRelease, &RadarrRelease) -> Ordering = + |a, b| a.quality.cmp(&b.quality); let mut expected_releases_vec = release_vec(); expected_releases_vec.sort_by(expected_cmp_fn); @@ -2040,7 +2055,9 @@ mod tests { .set_items(vec![MovieHistoryItem::default()]); modal.movie_cast.set_items(vec![Credit::default()]); modal.movie_crew.set_items(vec![Credit::default()]); - modal.movie_releases.set_items(vec![Release::default()]); + modal + .movie_releases + .set_items(vec![RadarrRelease::default()]); app.data.radarr_data.movie_details_modal = Some(modal); let handler = MovieDetailsHandler::with( @@ -2149,7 +2166,9 @@ mod tests { let mut app = App::default(); app.is_loading = false; let mut modal = MovieDetailsModal::default(); - modal.movie_releases.set_items(vec![Release::default()]); + modal + .movie_releases + .set_items(vec![RadarrRelease::default()]); app.data.radarr_data.movie_details_modal = Some(modal); let handler = MovieDetailsHandler::with( @@ -2162,8 +2181,8 @@ mod tests { assert!(handler.is_ready()); } - fn release_vec() -> Vec { - let release_a = Release { + fn release_vec() -> Vec { + let release_a = RadarrRelease { protocol: "Protocol A".to_owned(), age: 1, title: HorizontallyScrollableText::from("Title A"), @@ -2179,9 +2198,9 @@ mod tests { name: "Quality A".to_owned(), }, }, - ..Release::default() + ..RadarrRelease::default() }; - let release_b = Release { + let release_b = RadarrRelease { protocol: "Protocol B".to_owned(), age: 2, title: HorizontallyScrollableText::from("title B"), @@ -2197,9 +2216,9 @@ mod tests { name: "Quality B".to_owned(), }, }, - ..Release::default() + ..RadarrRelease::default() }; - let release_c = Release { + let release_c = RadarrRelease { protocol: "Protocol C".to_owned(), age: 3, title: HorizontallyScrollableText::from("Title C"), @@ -2213,13 +2232,13 @@ mod tests { name: "Quality C".to_owned(), }, }, - ..Release::default() + ..RadarrRelease::default() }; vec![release_a, release_b, release_c] } - fn sort_options() -> Vec> { + fn sort_options() -> Vec> { vec![SortOption { name: "Test 1", cmp_fn: Some(|a, b| a.age.cmp(&b.age)), diff --git a/src/models/radarr_models.rs b/src/models/radarr_models.rs index 5675502..cf37f7c 100644 --- a/src/models/radarr_models.rs +++ b/src/models/radarr_models.rs @@ -11,7 +11,7 @@ use crate::{models::HorizontallyScrollableText, serde_enum_from}; use super::servarr_models::{ DiskSpace, HostConfig, Indexer, Language, LogResponse, QualityProfile, QualityWrapper, - QueueEvent, Release, RootFolder, SecurityConfig, Tag, Update, + QueueEvent, RootFolder, SecurityConfig, Tag, Update, }; use super::{EnumDisplayStyle, Serdeable}; @@ -412,6 +412,28 @@ pub struct RatingsList { pub rotten_tomatoes: Option, } +#[derive(Serialize, Deserialize, Default, Clone, Debug, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +#[serde(default)] +pub struct RadarrRelease { + pub guid: String, + pub protocol: String, + #[serde(deserialize_with = "super::from_i64")] + pub age: i64, + pub title: HorizontallyScrollableText, + pub indexer: String, + #[serde(deserialize_with = "super::from_i64")] + pub indexer_id: i64, + #[serde(deserialize_with = "super::from_i64")] + pub size: i64, + pub rejected: bool, + pub rejections: Option>, + pub seeders: Option, + pub leechers: Option, + pub languages: Option>, + pub quality: QualityWrapper, +} + #[derive(Default, Serialize, Debug, PartialEq, Eq, Clone)] #[serde(rename_all = "camelCase")] pub struct RadarrReleaseDownloadBody { @@ -485,7 +507,7 @@ pub enum RadarrSerdeable { Movies(Vec), QualityProfiles(Vec), QueueEvents(Vec), - Releases(Vec), + Releases(Vec), RootFolders(Vec), SecurityConfig(SecurityConfig), SystemStatus(SystemStatus), @@ -526,7 +548,7 @@ serde_enum_from!( Movies(Vec), QualityProfiles(Vec), QueueEvents(Vec), - Releases(Vec), + Releases(Vec), RootFolders(Vec), SecurityConfig(SecurityConfig), SystemStatus(SystemStatus), diff --git a/src/models/radarr_models_tests.rs b/src/models/radarr_models_tests.rs index 4afe5a1..d49c3cd 100644 --- a/src/models/radarr_models_tests.rs +++ b/src/models/radarr_models_tests.rs @@ -7,8 +7,8 @@ mod tests { radarr_models::{ AddMovieSearchResult, BlocklistItem, BlocklistResponse, Collection, Credit, DiskSpace, DownloadRecord, DownloadsResponse, Indexer, IndexerSettings, IndexerTestResult, - MinimumAvailability, Monitor, Movie, MovieHistoryItem, QualityProfile, RadarrSerdeable, - RadarrTask, RadarrTaskName, Release, SystemStatus, Tag, Update, + MinimumAvailability, Monitor, Movie, MovieHistoryItem, QualityProfile, RadarrRelease, + RadarrSerdeable, RadarrTask, RadarrTaskName, SystemStatus, Tag, Update, }, servarr_models::{HostConfig, Log, LogResponse, QueueEvent, RootFolder, SecurityConfig}, EnumDisplayStyle, Serdeable, @@ -317,9 +317,9 @@ mod tests { #[test] fn test_radarr_serdeable_from_releases() { - let releases = vec![Release { + let releases = vec![RadarrRelease { size: 1, - ..Release::default() + ..RadarrRelease::default() }]; let radarr_serdeable: RadarrSerdeable = releases.clone().into(); diff --git a/src/models/servarr_data/radarr/modals.rs b/src/models/servarr_data/radarr/modals.rs index 2d7c893..9a98c4e 100644 --- a/src/models/servarr_data/radarr/modals.rs +++ b/src/models/servarr_data/radarr/modals.rs @@ -1,10 +1,10 @@ use strum::IntoEnumIterator; use crate::models::radarr_models::{ - Collection, Credit, MinimumAvailability, Monitor, Movie, MovieHistoryItem, + Collection, Credit, MinimumAvailability, Monitor, Movie, MovieHistoryItem, RadarrRelease, }; use crate::models::servarr_data::radarr::radarr_data::RadarrData; -use crate::models::servarr_models::{Indexer, Release, RootFolder}; +use crate::models::servarr_models::{Indexer, RootFolder}; use crate::models::stateful_list::StatefulList; use crate::models::stateful_table::StatefulTable; use crate::models::{HorizontallyScrollableText, ScrollableText}; @@ -22,7 +22,7 @@ pub struct MovieDetailsModal { pub movie_history: StatefulTable, pub movie_cast: StatefulTable, pub movie_crew: StatefulTable, - pub movie_releases: StatefulTable, + pub movie_releases: StatefulTable, } #[derive(Default, Debug, PartialEq, Eq)] diff --git a/src/models/servarr_data/radarr/radarr_test_utils.rs b/src/models/servarr_data/radarr/radarr_test_utils.rs index b437060..a3c5469 100644 --- a/src/models/servarr_data/radarr/radarr_test_utils.rs +++ b/src/models/servarr_data/radarr/radarr_test_utils.rs @@ -1,11 +1,10 @@ #[cfg(test)] pub mod utils { use crate::models::radarr_models::{ - AddMovieSearchResult, CollectionMovie, Credit, MovieHistoryItem, + AddMovieSearchResult, CollectionMovie, Credit, MovieHistoryItem, RadarrRelease, }; use crate::models::servarr_data::radarr::modals::MovieDetailsModal; use crate::models::servarr_data::radarr::radarr_data::RadarrData; - use crate::models::servarr_models::Release; use crate::models::stateful_table::StatefulTable; use crate::models::{HorizontallyScrollableText, ScrollableText}; @@ -25,7 +24,7 @@ pub mod utils { .set_items(vec![Credit::default()]); movie_details_modal .movie_releases - .set_items(vec![Release::default()]); + .set_items(vec![RadarrRelease::default()]); let mut radarr_data = RadarrData { delete_movie_files: true, diff --git a/src/models/servarr_data/sonarr/modals.rs b/src/models/servarr_data/sonarr/modals.rs index 71dbb96..4abf22c 100644 --- a/src/models/servarr_data/sonarr/modals.rs +++ b/src/models/servarr_data/sonarr/modals.rs @@ -1,6 +1,5 @@ use crate::models::{ - servarr_models::Release, - sonarr_models::{Episode, SonarrHistoryItem}, + sonarr_models::{Episode, SonarrHistoryItem, SonarrRelease}, stateful_table::StatefulTable, ScrollableText, }; @@ -12,12 +11,12 @@ pub struct EpisodeDetailsModal { pub audio_details: String, pub video_details: String, pub episode_history: StatefulTable, - pub episode_releases: StatefulTable, + pub episode_releases: StatefulTable, } #[derive(Default)] pub struct SeasonDetailsModal { pub episodes: StatefulTable, pub episode_details_modal: Option, - pub season_releases: StatefulTable, + pub season_releases: StatefulTable, } diff --git a/src/models/servarr_models.rs b/src/models/servarr_models.rs index 3c2b5c1..0089f5f 100644 --- a/src/models/servarr_models.rs +++ b/src/models/servarr_models.rs @@ -195,28 +195,6 @@ pub struct QueueEvent { pub duration: Option, } -#[derive(Serialize, Deserialize, Default, Clone, Debug, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -#[serde(default)] -pub struct Release { - pub guid: String, - pub protocol: String, - #[serde(deserialize_with = "super::from_i64")] - pub age: i64, - pub title: HorizontallyScrollableText, - pub indexer: String, - #[serde(deserialize_with = "super::from_i64")] - pub indexer_id: i64, - #[serde(deserialize_with = "super::from_i64")] - pub size: i64, - pub rejected: bool, - pub rejections: Option>, - pub seeders: Option, - pub leechers: Option, - pub languages: Option>, - pub quality: QualityWrapper, -} - #[derive(Default, Serialize, Deserialize, Debug, Clone, Eq, PartialEq)] #[serde(rename_all = "camelCase")] pub struct RootFolder { diff --git a/src/models/sonarr_models.rs b/src/models/sonarr_models.rs index 92cd9e8..2f72ab7 100644 --- a/src/models/sonarr_models.rs +++ b/src/models/sonarr_models.rs @@ -13,7 +13,7 @@ use super::{ radarr_models::IndexerTestResult, servarr_models::{ DiskSpace, HostConfig, Indexer, Language, LogResponse, QualityProfile, QualityWrapper, - QueueEvent, Release, RootFolder, SecurityConfig, Tag, Update, + QueueEvent, RootFolder, SecurityConfig, Tag, Update, }, EnumDisplayStyle, HorizontallyScrollableText, Serdeable, }; @@ -401,6 +401,28 @@ pub struct SonarrCommandBody { pub episode_ids: Option>, } +#[derive(Serialize, Deserialize, Default, Clone, Debug, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +#[serde(default)] +pub struct SonarrRelease { + pub guid: String, + pub protocol: String, + #[serde(deserialize_with = "super::from_i64")] + pub age: i64, + pub title: HorizontallyScrollableText, + pub indexer: String, + #[serde(deserialize_with = "super::from_i64")] + pub indexer_id: i64, + #[serde(deserialize_with = "super::from_i64")] + pub size: i64, + pub rejected: bool, + pub rejections: Option>, + pub seeders: Option, + pub leechers: Option, + pub languages: Option>, + pub quality: QualityWrapper, + pub full_season: bool, +} #[derive(Default, Serialize, Debug, PartialEq, Eq, Clone)] #[serde(rename_all = "camelCase")] pub struct SonarrReleaseDownloadBody { @@ -467,7 +489,7 @@ pub enum SonarrSerdeable { LogResponse(LogResponse), QualityProfiles(Vec), QueueEvents(Vec), - Releases(Vec), + Releases(Vec), RootFolders(Vec), SecurityConfig(SecurityConfig), SeriesVec(Vec), @@ -508,7 +530,7 @@ serde_enum_from!( LogResponse(LogResponse), QualityProfiles(Vec), QueueEvents(Vec), - Releases(Vec), + Releases(Vec), RootFolders(Vec), SecurityConfig(SecurityConfig), SeriesVec(Vec), diff --git a/src/models/sonarr_models_tests.rs b/src/models/sonarr_models_tests.rs index a56af3d..0a6aff3 100644 --- a/src/models/sonarr_models_tests.rs +++ b/src/models/sonarr_models_tests.rs @@ -6,13 +6,13 @@ mod tests { use crate::models::{ radarr_models::IndexerTestResult, servarr_models::{ - DiskSpace, HostConfig, Indexer, Log, LogResponse, QualityProfile, QueueEvent, Release, - RootFolder, SecurityConfig, Tag, Update, + DiskSpace, HostConfig, Indexer, Log, LogResponse, QualityProfile, QueueEvent, RootFolder, + SecurityConfig, Tag, Update, }, sonarr_models::{ BlocklistItem, BlocklistResponse, DownloadRecord, DownloadsResponse, Episode, IndexerSettings, Series, SeriesStatus, SeriesType, SonarrHistoryEventType, SonarrHistoryItem, - SonarrSerdeable, SonarrTask, SonarrTaskName, SystemStatus, + SonarrRelease, SonarrSerdeable, SonarrTask, SonarrTaskName, SystemStatus, }, EnumDisplayStyle, Serdeable, }; @@ -355,9 +355,9 @@ mod tests { #[test] fn test_sonarr_serdeable_from_releases() { - let releases = vec![Release { + let releases = vec![SonarrRelease { size: 1, - ..Release::default() + ..SonarrRelease::default() }]; let sonarr_serdeable: SonarrSerdeable = releases.clone().into(); diff --git a/src/network/radarr_network.rs b/src/network/radarr_network.rs index e195118..82b2b1b 100644 --- a/src/network/radarr_network.rs +++ b/src/network/radarr_network.rs @@ -10,8 +10,8 @@ use crate::models::radarr_models::{ AddMovieBody, AddMovieSearchResult, AddOptions, BlocklistResponse, Collection, CollectionMovie, Credit, CreditType, DeleteMovieParams, DownloadRecord, DownloadsResponse, EditCollectionParams, EditIndexerParams, EditMovieParams, IndexerSettings, IndexerTestResult, Movie, MovieCommandBody, - MovieHistoryItem, RadarrReleaseDownloadBody, RadarrSerdeable, RadarrTask, RadarrTaskName, - SystemStatus, + MovieHistoryItem, RadarrRelease, RadarrReleaseDownloadBody, RadarrSerdeable, RadarrTask, + RadarrTaskName, SystemStatus, }; use crate::models::servarr_data::modals::IndexerTestResultModalItem; use crate::models::servarr_data::radarr::modals::{ @@ -20,7 +20,7 @@ use crate::models::servarr_data::radarr::modals::{ use crate::models::servarr_data::radarr::radarr_data::ActiveRadarrBlock; use crate::models::servarr_models::{ AddRootFolderBody, CommandBody, DiskSpace, HostConfig, Indexer, LogResponse, QualityProfile, - QueueEvent, Release, RootFolder, SecurityConfig, Tag, Update, + QueueEvent, RootFolder, SecurityConfig, Tag, Update, }; use crate::models::stateful_table::StatefulTable; use crate::models::{HorizontallyScrollableText, Route, Scrollable, ScrollableText}; @@ -675,7 +675,7 @@ impl<'a, 'b> Network<'a, 'b> { let (movie_id, _) = self.extract_movie_id(None).await; let (guid, title, indexer_id) = { let app = self.app.lock().await; - let Release { + let RadarrRelease { guid, title, indexer_id, @@ -1770,7 +1770,7 @@ impl<'a, 'b> Network<'a, 'b> { .await } - async fn get_movie_releases(&mut self, movie_id: Option) -> Result> { + async fn get_movie_releases(&mut self, movie_id: Option) -> Result> { let (id, movie_id_param) = self.extract_movie_id(movie_id).await; info!("Fetching releases for movie with ID: {id}"); let event = RadarrEvent::GetReleases(None); @@ -1786,7 +1786,7 @@ impl<'a, 'b> Network<'a, 'b> { .await; self - .handle_request::<(), Vec>(request_props, |release_vec, mut app| { + .handle_request::<(), Vec>(request_props, |release_vec, mut app| { if app.data.radarr_data.movie_details_modal.is_none() { app.data.radarr_data.movie_details_modal = Some(MovieDetailsModal::default()); } diff --git a/src/network/radarr_network_tests.rs b/src/network/radarr_network_tests.rs index 75703eb..f695f1a 100644 --- a/src/network/radarr_network_tests.rs +++ b/src/network/radarr_network_tests.rs @@ -5221,8 +5221,8 @@ mod test { QualityWrapper { quality: quality() } } - fn release() -> Release { - Release { + fn release() -> RadarrRelease { + RadarrRelease { guid: "1234".to_owned(), protocol: "torrent".to_owned(), age: 1, diff --git a/src/network/sonarr_network.rs b/src/network/sonarr_network.rs index 1facb66..ae96b8f 100644 --- a/src/network/sonarr_network.rs +++ b/src/network/sonarr_network.rs @@ -15,12 +15,12 @@ use crate::{ }, servarr_models::{ AddRootFolderBody, CommandBody, DiskSpace, HostConfig, Indexer, LogResponse, QualityProfile, - QueueEvent, Release, RootFolder, SecurityConfig, Tag, Update, + QueueEvent, RootFolder, SecurityConfig, Tag, Update, }, sonarr_models::{ BlocklistResponse, DownloadRecord, DownloadsResponse, Episode, IndexerSettings, Series, - SonarrCommandBody, SonarrHistoryItem, SonarrHistoryWrapper, SonarrReleaseDownloadBody, - SonarrSerdeable, SonarrTask, SonarrTaskName, SystemStatus, + SonarrCommandBody, SonarrHistoryItem, SonarrHistoryWrapper, SonarrRelease, + SonarrReleaseDownloadBody, SonarrSerdeable, SonarrTask, SonarrTaskName, SystemStatus, }, stateful_table::StatefulTable, HorizontallyScrollableText, Route, Scrollable, ScrollableText, @@ -1009,7 +1009,7 @@ impl<'a, 'b> Network<'a, 'b> { .await } - async fn get_episode_releases(&mut self, episode_id: Option) -> Result> { + async fn get_episode_releases(&mut self, episode_id: Option) -> Result> { let event = SonarrEvent::GetEpisodeReleases(None); let id = self.extract_episode_id(episode_id).await; @@ -1026,7 +1026,7 @@ impl<'a, 'b> Network<'a, 'b> { .await; self - .handle_request::<(), Vec>(request_props, |release_vec, mut app| { + .handle_request::<(), Vec>(request_props, |release_vec, mut app| { if app.data.sonarr_data.season_details_modal.is_none() { app.data.sonarr_data.season_details_modal = Some(SeasonDetailsModal::default()); } @@ -1067,7 +1067,7 @@ impl<'a, 'b> Network<'a, 'b> { async fn get_season_releases( &mut self, series_season_id_tuple: Option<(i64, i64)>, - ) -> Result> { + ) -> Result> { let event = SonarrEvent::GetSeasonReleases(None); let (series_id, season_number) = if let Some((series_id, season_number)) = series_season_id_tuple { @@ -1092,11 +1092,16 @@ impl<'a, 'b> Network<'a, 'b> { .await; self - .handle_request::<(), Vec>(request_props, |release_vec, mut app| { + .handle_request::<(), Vec>(request_props, |release_vec, mut app| { if app.data.sonarr_data.season_details_modal.is_none() { app.data.sonarr_data.season_details_modal = Some(SeasonDetailsModal::default()); } + let season_releases_vec = release_vec + .into_iter() + .filter(|release| release.full_season) + .collect(); + app .data .sonarr_data @@ -1104,7 +1109,7 @@ impl<'a, 'b> Network<'a, 'b> { .as_mut() .unwrap() .season_releases - .set_items(release_vec); + .set_items(season_releases_vec); }) .await } diff --git a/src/network/sonarr_network_tests.rs b/src/network/sonarr_network_tests.rs index 260d44a..a3b86cc 100644 --- a/src/network/sonarr_network_tests.rs +++ b/src/network/sonarr_network_tests.rs @@ -21,11 +21,11 @@ mod test { use crate::models::servarr_data::sonarr::sonarr_data::ActiveSonarrBlock; use crate::models::servarr_models::{ DiskSpace, HostConfig, Indexer, IndexerField, Language, LogResponse, Quality, QualityProfile, - QualityWrapper, QueueEvent, Release, RootFolder, SecurityConfig, Tag, Update, + QualityWrapper, QueueEvent, RootFolder, SecurityConfig, Tag, Update, }; use crate::models::sonarr_models::{ BlocklistItem, DownloadRecord, DownloadsResponse, Episode, EpisodeFile, MediaInfo, - SonarrReleaseDownloadBody, SonarrTaskName, + SonarrRelease, SonarrReleaseDownloadBody, SonarrTaskName, }; use crate::models::sonarr_models::{ BlocklistResponse, SonarrHistoryData, SonarrHistoryItem, SonarrHistoryWrapper, @@ -612,7 +612,7 @@ mod test { Some(json!({ "guid": "1234", "indexerId": 2, - "seriesId": 1 + "seriesId": 1, })), Some(json!({})), None, @@ -2712,21 +2712,53 @@ mod test { #[tokio::test] async fn test_handle_get_season_releases_event() { - let release_json = json!([{ - "guid": "1234", - "protocol": "torrent", - "age": 1, - "title": "Test Release", - "indexer": "kickass torrents", - "indexerId": 2, - "size": 1234, - "rejected": true, - "rejections": [ "Unknown quality profile", "Release is already mapped" ], - "seeders": 2, - "leechers": 1, - "languages": [ { "name": "English" } ], - "quality": { "quality": { "name": "Bluray-1080p" }} - }]); + let release_json = json!([ + { + "guid": "1234", + "protocol": "torrent", + "age": 1, + "title": "Test Release", + "indexer": "kickass torrents", + "indexerId": 2, + "size": 1234, + "rejected": true, + "rejections": [ "Unknown quality profile", "Release is already mapped" ], + "seeders": 2, + "leechers": 1, + "languages": [ { "name": "English" } ], + "quality": { "quality": { "name": "Bluray-1080p" }}, + "fullSeason": true + }, + { + "guid": "4567", + "protocol": "torrent", + "age": 1, + "title": "Test Release", + "indexer": "kickass torrents", + "indexerId": 2, + "size": 1234, + "rejected": true, + "rejections": [ "Unknown quality profile", "Release is already mapped" ], + "seeders": 2, + "leechers": 1, + "languages": [ { "name": "English" } ], + "quality": { "quality": { "name": "Bluray-1080p" }}, + } + ]); + let expected_filtered_sonarr_release = SonarrRelease { + full_season: true, + ..release() + }; + let expected_raw_sonarr_releases = vec![ + SonarrRelease { + full_season: true, + ..release() + }, + SonarrRelease { + guid: "4567".to_owned(), + ..release() + }, + ]; let (async_server, app_arc, _server) = mock_servarr_api( RequestMethod::Get, None, @@ -2772,29 +2804,51 @@ mod test { .unwrap() .season_releases .items, - vec![release()] + vec![expected_filtered_sonarr_release] ); - assert_eq!(releases_vec, vec![release()]); + assert_eq!(releases_vec, expected_raw_sonarr_releases); } } #[tokio::test] async fn test_handle_get_season_releases_event_empty_season_details_modal() { - let release_json = json!([{ - "guid": "1234", - "protocol": "torrent", - "age": 1, - "title": "Test Release", - "indexer": "kickass torrents", - "indexerId": 2, - "size": 1234, - "rejected": true, - "rejections": [ "Unknown quality profile", "Release is already mapped" ], - "seeders": 2, - "leechers": 1, - "languages": [ { "name": "English" } ], - "quality": { "quality": { "name": "Bluray-1080p" }} - }]); + let release_json = json!([ + { + "guid": "1234", + "protocol": "torrent", + "age": 1, + "title": "Test Release", + "indexer": "kickass torrents", + "indexerId": 2, + "size": 1234, + "rejected": true, + "rejections": [ "Unknown quality profile", "Release is already mapped" ], + "seeders": 2, + "leechers": 1, + "languages": [ { "name": "English" } ], + "quality": { "quality": { "name": "Bluray-1080p" }}, + "fullSeason": true + }, + { + "guid": "4567", + "protocol": "usenet", + "age": 1, + "title": "Test Release", + "indexer": "kickass torrents", + "indexerId": 2, + "size": 1234, + "rejected": true, + "rejections": [ "Unknown quality profile", "Release is already mapped" ], + "seeders": 2, + "leechers": 1, + "languages": [ { "name": "English" } ], + "quality": { "quality": { "name": "Bluray-1080p" }}, + } + ]); + let expected_sonarr_release = SonarrRelease { + full_season: true, + ..release() + }; let (async_server, app_arc, _server) = mock_servarr_api( RequestMethod::Get, None, @@ -2838,27 +2892,59 @@ mod test { .unwrap() .season_releases .items, - vec![release()] + vec![expected_sonarr_release] ); } #[tokio::test] async fn test_handle_get_season_releases_event_uses_provided_series_id_and_season_number() { - let release_json = json!([{ - "guid": "1234", - "protocol": "torrent", - "age": 1, - "title": "Test Release", - "indexer": "kickass torrents", - "indexerId": 2, - "size": 1234, - "rejected": true, - "rejections": [ "Unknown quality profile", "Release is already mapped" ], - "seeders": 2, - "leechers": 1, - "languages": [ { "name": "English" } ], - "quality": { "quality": { "name": "Bluray-1080p" }} - }]); + let release_json = json!([ + { + "guid": "1234", + "protocol": "torrent", + "age": 1, + "title": "Test Release", + "indexer": "kickass torrents", + "indexerId": 2, + "size": 1234, + "rejected": true, + "rejections": [ "Unknown quality profile", "Release is already mapped" ], + "seeders": 2, + "leechers": 1, + "languages": [ { "name": "English" } ], + "quality": { "quality": { "name": "Bluray-1080p" }}, + "fullSeason": true + }, + { + "guid": "4567", + "protocol": "torrent", + "age": 1, + "title": "Test Release", + "indexer": "kickass torrents", + "indexerId": 2, + "size": 1234, + "rejected": true, + "rejections": [ "Unknown quality profile", "Release is already mapped" ], + "seeders": 2, + "leechers": 1, + "languages": [ { "name": "English" } ], + "quality": { "quality": { "name": "Bluray-1080p" }}, + } + ]); + let expected_filtered_sonarr_release = SonarrRelease { + full_season: true, + ..release() + }; + let expected_raw_sonarr_releases = vec![ + SonarrRelease { + full_season: true, + ..release() + }, + SonarrRelease { + guid: "4567".to_owned(), + ..release() + }, + ]; let (async_server, app_arc, _server) = mock_servarr_api( RequestMethod::Get, None, @@ -2904,29 +2990,61 @@ mod test { .unwrap() .season_releases .items, - vec![release()] + vec![expected_filtered_sonarr_release] ); - assert_eq!(releases_vec, vec![release()]); + assert_eq!(releases_vec, expected_raw_sonarr_releases); } } #[tokio::test] async fn test_handle_get_season_releases_event_filtered_series_and_filtered_seasons() { - let release_json = json!([{ - "guid": "1234", - "protocol": "torrent", - "age": 1, - "title": "Test Release", - "indexer": "kickass torrents", - "indexerId": 2, - "size": 1234, - "rejected": true, - "rejections": [ "Unknown quality profile", "Release is already mapped" ], - "seeders": 2, - "leechers": 1, - "languages": [ { "name": "English" } ], - "quality": { "quality": { "name": "Bluray-1080p" }} - }]); + let release_json = json!([ + { + "guid": "1234", + "protocol": "torrent", + "age": 1, + "title": "Test Release", + "indexer": "kickass torrents", + "indexerId": 2, + "size": 1234, + "rejected": true, + "rejections": [ "Unknown quality profile", "Release is already mapped" ], + "seeders": 2, + "leechers": 1, + "languages": [ { "name": "English" } ], + "quality": { "quality": { "name": "Bluray-1080p" }}, + "fullSeason": true + }, + { + "guid": "4567", + "protocol": "torrent", + "age": 1, + "title": "Test Release", + "indexer": "kickass torrents", + "indexerId": 2, + "size": 1234, + "rejected": true, + "rejections": [ "Unknown quality profile", "Release is already mapped" ], + "seeders": 2, + "leechers": 1, + "languages": [ { "name": "English" } ], + "quality": { "quality": { "name": "Bluray-1080p" }}, + } + ]); + let expected_filtered_sonarr_release = SonarrRelease { + full_season: true, + ..release() + }; + let expected_raw_sonarr_releases = vec![ + SonarrRelease { + full_season: true, + ..release() + }, + SonarrRelease { + guid: "4567".to_owned(), + ..release() + }, + ]; let (async_server, app_arc, _server) = mock_servarr_api( RequestMethod::Get, None, @@ -2970,9 +3088,9 @@ mod test { .unwrap() .season_releases .items, - vec![release()] + vec![expected_filtered_sonarr_release] ); - assert_eq!(releases_vec, vec![release()]); + assert_eq!(releases_vec, expected_raw_sonarr_releases); } } @@ -5097,8 +5215,8 @@ mod test { ] } - fn release() -> Release { - Release { + fn release() -> SonarrRelease { + SonarrRelease { guid: "1234".to_owned(), protocol: "torrent".to_owned(), age: 1, @@ -5112,6 +5230,7 @@ mod test { leechers: Some(Number::from(1)), languages: Some(vec![language()]), quality: quality_wrapper(), + full_season: false, } } diff --git a/src/ui/radarr_ui/library/movie_details_ui.rs b/src/ui/radarr_ui/library/movie_details_ui.rs index 36699a6..ff7dd77 100644 --- a/src/ui/radarr_ui/library/movie_details_ui.rs +++ b/src/ui/radarr_ui/library/movie_details_ui.rs @@ -7,10 +7,9 @@ use ratatui::widgets::{Cell, Paragraph, Row, Wrap}; use ratatui::Frame; use crate::app::App; -use crate::models::radarr_models::{Credit, MovieHistoryItem}; +use crate::models::radarr_models::{Credit, MovieHistoryItem, RadarrRelease}; use crate::models::servarr_data::radarr::modals::MovieDetailsModal; use crate::models::servarr_data::radarr::radarr_data::{ActiveRadarrBlock, MOVIE_DETAILS_BLOCKS}; -use crate::models::servarr_models::Release; use crate::models::Route; use crate::ui::radarr_ui::library::draw_library; use crate::ui::styles::ManagarrStyle; @@ -381,7 +380,7 @@ fn draw_movie_releases(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { .clone(), movie_details_modal.movie_releases.items.is_empty(), ), - _ => (Release::default(), true), + _ => (RadarrRelease::default(), true), }; let current_route = *app.get_current_route(); let mut default_movie_details_modal = MovieDetailsModal::default(); @@ -399,8 +398,8 @@ fn draw_movie_releases(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { .unwrap_or(&mut default_movie_details_modal) .movie_releases, ); - let releases_row_mapping = |release: &Release| { - let Release { + let releases_row_mapping = |release: &RadarrRelease| { + let RadarrRelease { protocol, age, title, From 374819b4f360e3ecfe0f3be47ada796544b7e8eb Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Sat, 23 Nov 2024 12:23:33 -0700 Subject: [PATCH 093/119] fix(network): Made the overview field nullable in the Sonarr series model --- src/models/sonarr_models.rs | 2 +- src/network/sonarr_network_tests.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/models/sonarr_models.rs b/src/models/sonarr_models.rs index 2f72ab7..ac253f8 100644 --- a/src/models/sonarr_models.rs +++ b/src/models/sonarr_models.rs @@ -215,7 +215,7 @@ pub struct Series { pub ratings: Rating, pub ended: bool, pub status: SeriesStatus, - pub overview: String, + pub overview: Option, pub network: Option, pub season_folder: bool, pub certification: Option, diff --git a/src/network/sonarr_network_tests.rs b/src/network/sonarr_network_tests.rs index a3b86cc..f0c7d88 100644 --- a/src/network/sonarr_network_tests.rs +++ b/src/network/sonarr_network_tests.rs @@ -5176,7 +5176,7 @@ mod test { title: "Test".to_owned().into(), status: SeriesStatus::Continuing, ended: false, - overview: "Blah blah blah".into(), + overview: Some("Blah blah blah".to_owned()), network: Some("HBO".to_owned()), seasons: Some(vec![season()]), year: 2022, From cac54c544763f8f96d4f84e81cfe4106147dd252 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Sat, 23 Nov 2024 12:42:11 -0700 Subject: [PATCH 094/119] feat(network): Support for deleting a series from Sonarr --- src/models/servarr_data/sonarr/sonarr_data.rs | 13 ++++ .../servarr_data/sonarr/sonarr_data_tests.rs | 16 +++++ src/models/sonarr_models.rs | 8 +++ src/network/sonarr_network.rs | 64 +++++++++++++++-- src/network/sonarr_network_tests.rs | 70 ++++++++++++++++++- 5 files changed, 164 insertions(+), 7 deletions(-) diff --git a/src/models/servarr_data/sonarr/sonarr_data.rs b/src/models/servarr_data/sonarr/sonarr_data.rs index 6d44336..ea91074 100644 --- a/src/models/servarr_data/sonarr/sonarr_data.rs +++ b/src/models/servarr_data/sonarr/sonarr_data.rs @@ -20,7 +20,9 @@ use super::modals::SeasonDetailsModal; mod sonarr_data_tests; pub struct SonarrData { + pub add_list_exclusion: bool, pub blocklist: StatefulTable, + pub delete_series_files: bool, pub downloads: StatefulTable, pub disk_space_vec: Vec, pub edit_root_folder: Option, @@ -44,11 +46,20 @@ pub struct SonarrData { pub version: String, } +impl SonarrData { + pub fn reset_delete_series_preferences(&mut self) { + self.delete_series_files = false; + self.add_list_exclusion = false; + } +} + impl Default for SonarrData { fn default() -> SonarrData { SonarrData { + add_list_exclusion: false, blocklist: StatefulTable::default(), downloads: StatefulTable::default(), + delete_series_files: false, disk_space_vec: Vec::new(), edit_root_folder: None, history: StatefulTable::default(), @@ -97,6 +108,8 @@ pub enum ActiveSonarrBlock { DeleteRootFolderPrompt, DeleteSeriesConfirmPrompt, DeleteSeriesPrompt, + DeleteSeriesToggleAddListExclusion, + DeleteSeriesToggleDeleteFile, Downloads, EditEpisodePrompt, EditIndexerPrompt, diff --git a/src/models/servarr_data/sonarr/sonarr_data_tests.rs b/src/models/servarr_data/sonarr/sonarr_data_tests.rs index b17150a..593cbf3 100644 --- a/src/models/servarr_data/sonarr/sonarr_data_tests.rs +++ b/src/models/servarr_data/sonarr/sonarr_data_tests.rs @@ -30,11 +30,27 @@ mod tests { ); } + #[test] + fn test_reset_delete_series_preferences() { + let mut sonarr_data = SonarrData { + add_list_exclusion: true, + delete_series_files: true, + ..SonarrData::default() + }; + + sonarr_data.reset_delete_series_preferences(); + + assert!(!sonarr_data.delete_series_files); + assert!(!sonarr_data.add_list_exclusion); + } + #[test] fn test_sonarr_data_defaults() { let sonarr_data = SonarrData::default(); + assert!(!sonarr_data.add_list_exclusion); assert!(sonarr_data.blocklist.is_empty()); + assert!(!sonarr_data.delete_series_files); assert!(sonarr_data.downloads.is_empty()); assert!(sonarr_data.disk_space_vec.is_empty()); assert!(sonarr_data.edit_root_folder.is_none()); diff --git a/src/models/sonarr_models.rs b/src/models/sonarr_models.rs index ac253f8..caac288 100644 --- a/src/models/sonarr_models.rs +++ b/src/models/sonarr_models.rs @@ -44,6 +44,14 @@ pub struct BlocklistResponse { pub records: Vec, } +#[derive(Serialize, Deserialize, Debug, Clone, Eq, PartialEq)] +#[serde(rename_all = "lowercase")] +pub struct DeleteSeriesParams { + pub id: i64, + pub delete_series_files: bool, + pub add_list_exclusion: bool, +} + #[derive(Derivative, Serialize, Deserialize, Debug, Default, Clone, PartialEq)] #[serde(rename_all = "camelCase")] pub struct DownloadRecord { diff --git a/src/network/sonarr_network.rs b/src/network/sonarr_network.rs index ae96b8f..15a221b 100644 --- a/src/network/sonarr_network.rs +++ b/src/network/sonarr_network.rs @@ -18,9 +18,10 @@ use crate::{ QueueEvent, RootFolder, SecurityConfig, Tag, Update, }, sonarr_models::{ - BlocklistResponse, DownloadRecord, DownloadsResponse, Episode, IndexerSettings, Series, - SonarrCommandBody, SonarrHistoryItem, SonarrHistoryWrapper, SonarrRelease, - SonarrReleaseDownloadBody, SonarrSerdeable, SonarrTask, SonarrTaskName, SystemStatus, + BlocklistResponse, DeleteSeriesParams, DownloadRecord, DownloadsResponse, Episode, + IndexerSettings, Series, SonarrCommandBody, SonarrHistoryItem, SonarrHistoryWrapper, + SonarrRelease, SonarrReleaseDownloadBody, SonarrSerdeable, SonarrTask, SonarrTaskName, + SystemStatus, }, stateful_table::StatefulTable, HorizontallyScrollableText, Route, Scrollable, ScrollableText, @@ -43,6 +44,7 @@ pub enum SonarrEvent { DeleteDownload(Option), DeleteIndexer(Option), DeleteRootFolder(Option), + DeleteSeries(Option), DeleteTag(i64), DownloadRelease(SonarrReleaseDownloadBody), GetAllIndexerSettings, @@ -116,7 +118,9 @@ impl NetworkResource for SonarrEvent { SonarrEvent::GetTasks => "/system/task", SonarrEvent::GetUpdates => "/update", SonarrEvent::HealthCheck => "/health", - SonarrEvent::ListSeries | SonarrEvent::GetSeriesDetails(_) => "/series", + SonarrEvent::ListSeries | SonarrEvent::GetSeriesDetails(_) | SonarrEvent::DeleteSeries(_) => { + "/series" + } SonarrEvent::MarkHistoryItemAsFailed(_) => "/history/failed", SonarrEvent::TestIndexer(_) => "/indexer/test", SonarrEvent::TestAllIndexers => "/indexer/testall", @@ -165,6 +169,9 @@ impl<'a, 'b> Network<'a, 'b> { .delete_sonarr_root_folder(root_folder_id) .await .map(SonarrSerdeable::from), + SonarrEvent::DeleteSeries(params) => { + self.delete_series(params).await.map(SonarrSerdeable::from) + } SonarrEvent::DeleteTag(tag_id) => self .delete_sonarr_tag(tag_id) .await @@ -498,6 +505,55 @@ impl<'a, 'b> Network<'a, 'b> { .await } + async fn delete_series( + &mut self, + delete_series_params: Option, + ) -> Result<()> { + let event = SonarrEvent::DeleteSeries(None); + let (series_id, delete_files, add_import_exclusion) = if let Some(params) = delete_series_params + { + ( + params.id, + params.delete_series_files, + params.add_list_exclusion, + ) + } else { + let (series_id, _) = self.extract_series_id(None).await; + let delete_files = self.app.lock().await.data.sonarr_data.delete_series_files; + let add_import_exclusion = self.app.lock().await.data.sonarr_data.add_list_exclusion; + + (series_id, delete_files, add_import_exclusion) + }; + + info!("Deleting Sonarr series with ID: {series_id} with deleteFiles={delete_files} and addImportExclusion={add_import_exclusion}"); + + let request_props = self + .request_props_from( + event, + RequestMethod::Delete, + None::<()>, + Some(format!("/{series_id}")), + Some(format!( + "deleteFiles={delete_files}&addImportExclusion={add_import_exclusion}" + )), + ) + .await; + + let resp = self + .handle_request::<(), ()>(request_props, |_, _| ()) + .await; + + self + .app + .lock() + .await + .data + .sonarr_data + .reset_delete_series_preferences(); + + resp + } + async fn delete_sonarr_tag(&mut self, id: i64) -> Result<()> { info!("Deleting Sonarr tag with id: {id}"); let event = SonarrEvent::DeleteTag(id); diff --git a/src/network/sonarr_network_tests.rs b/src/network/sonarr_network_tests.rs index f0c7d88..b7d0625 100644 --- a/src/network/sonarr_network_tests.rs +++ b/src/network/sonarr_network_tests.rs @@ -24,8 +24,8 @@ mod test { QualityWrapper, QueueEvent, RootFolder, SecurityConfig, Tag, Update, }; use crate::models::sonarr_models::{ - BlocklistItem, DownloadRecord, DownloadsResponse, Episode, EpisodeFile, MediaInfo, - SonarrRelease, SonarrReleaseDownloadBody, SonarrTaskName, + BlocklistItem, DeleteSeriesParams, DownloadRecord, DownloadsResponse, Episode, EpisodeFile, + MediaInfo, SonarrRelease, SonarrReleaseDownloadBody, SonarrTaskName, }; use crate::models::sonarr_models::{ BlocklistResponse, SonarrHistoryData, SonarrHistoryItem, SonarrHistoryWrapper, @@ -137,7 +137,12 @@ mod test { #[rstest] fn test_resource_series( - #[values(SonarrEvent::ListSeries, SonarrEvent::GetSeriesDetails(None))] event: SonarrEvent, + #[values( + SonarrEvent::ListSeries, + SonarrEvent::GetSeriesDetails(None), + SonarrEvent::DeleteSeries(None) + )] + event: SonarrEvent, ) { assert_str_eq!(event.resource(), "/series"); } @@ -577,6 +582,65 @@ mod test { async_server.assert_async().await; } + #[tokio::test] + async fn test_handle_delete_series_event() { + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Delete, + None, + None, + None, + SonarrEvent::DeleteSeries(None), + Some("/1"), + Some("deleteFiles=true&addImportExclusion=true"), + ) + .await; + { + let mut app = app_arc.lock().await; + app.data.sonarr_data.series.set_items(vec![series()]); + app.data.sonarr_data.delete_series_files = true; + app.data.sonarr_data.add_list_exclusion = true; + } + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + assert!(network + .handle_sonarr_event(SonarrEvent::DeleteSeries(None)) + .await + .is_ok()); + + async_server.assert_async().await; + assert!(!app_arc.lock().await.data.sonarr_data.delete_series_files); + assert!(!app_arc.lock().await.data.sonarr_data.add_list_exclusion); + } + + #[tokio::test] + async fn test_handle_delete_series_event_use_provided_params() { + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Delete, + None, + None, + None, + SonarrEvent::DeleteSeries(None), + Some("/1"), + Some("deleteFiles=true&addImportExclusion=true"), + ) + .await; + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + let delete_series_params = DeleteSeriesParams { + id: 1, + delete_series_files: true, + add_list_exclusion: true, + }; + + assert!(network + .handle_sonarr_event(SonarrEvent::DeleteSeries(Some(delete_series_params))) + .await + .is_ok()); + + async_server.assert_async().await; + assert!(!app_arc.lock().await.data.sonarr_data.delete_series_files); + assert!(!app_arc.lock().await.data.sonarr_data.add_list_exclusion); + } + #[tokio::test] async fn test_handle_delete_sonarr_tag_event() { let (async_server, app_arc, _server) = mock_servarr_api( From c8a2fea9cdf7dd7fd40132a6966d89250178b08e Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Sat, 23 Nov 2024 12:47:22 -0700 Subject: [PATCH 095/119] feat(cli): Support for deleting a series from Sonarr --- src/cli/sonarr/delete_command_handler.rs | 26 +++++ .../sonarr/delete_command_handler_tests.rs | 96 ++++++++++++++++++- 2 files changed, 121 insertions(+), 1 deletion(-) diff --git a/src/cli/sonarr/delete_command_handler.rs b/src/cli/sonarr/delete_command_handler.rs index 32a9d28..15d052f 100644 --- a/src/cli/sonarr/delete_command_handler.rs +++ b/src/cli/sonarr/delete_command_handler.rs @@ -7,6 +7,7 @@ use tokio::sync::Mutex; use crate::{ app::App, cli::{CliCommandHandler, Command}, + models::sonarr_models::DeleteSeriesParams, network::{sonarr_network::SonarrEvent, NetworkTrait}, }; @@ -42,6 +43,15 @@ pub enum SonarrDeleteCommand { #[arg(long, help = "The ID of the root folder to delete", required = true)] root_folder_id: i64, }, + #[command(about = "Delete a series from your Sonarr library")] + Series { + #[arg(long, help = "The ID of the series to delete", required = true)] + series_id: i64, + #[arg(long, help = "Delete the series files from disk as well")] + delete_files_from_disk: bool, + #[arg(long, help = "Add a list exclusion for this series")] + add_list_exclusion: bool, + }, #[command(about = "Delete the tag with the specified ID")] Tag { #[arg(long, help = "The ID of the tag to delete", required = true)] @@ -104,6 +114,22 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, SonarrDeleteCommand> for SonarrDeleteComm .await?; serde_json::to_string_pretty(&resp)? } + SonarrDeleteCommand::Series { + series_id, + delete_files_from_disk, + add_list_exclusion, + } => { + let delete_series_params = DeleteSeriesParams { + id: series_id, + delete_series_files: delete_files_from_disk, + add_list_exclusion, + }; + let resp = self + .network + .handle_network_event(SonarrEvent::DeleteSeries(Some(delete_series_params)).into()) + .await?; + serde_json::to_string_pretty(&resp)? + } SonarrDeleteCommand::Tag { tag_id } => { let resp = self .network diff --git a/src/cli/sonarr/delete_command_handler_tests.rs b/src/cli/sonarr/delete_command_handler_tests.rs index 4400a90..35d7d4d 100644 --- a/src/cli/sonarr/delete_command_handler_tests.rs +++ b/src/cli/sonarr/delete_command_handler_tests.rs @@ -158,6 +158,63 @@ mod tests { } } + #[test] + fn test_delete_series_requires_arguments() { + let result = Cli::command().try_get_matches_from(["managarr", "sonarr", "delete", "series"]); + + assert!(result.is_err()); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_delete_series_defaults() { + let expected_args = SonarrDeleteCommand::Series { + series_id: 1, + delete_files_from_disk: false, + add_list_exclusion: false, + }; + + let result = + Cli::try_parse_from(["managarr", "sonarr", "delete", "series", "--series-id", "1"]); + + assert!(result.is_ok()); + + if let Some(Command::Sonarr(SonarrCommand::Delete(delete_command))) = result.unwrap().command + { + assert_eq!(delete_command, expected_args); + } + } + + #[test] + fn test_delete_series_all_args_defined() { + let expected_args = SonarrDeleteCommand::Series { + series_id: 1, + delete_files_from_disk: true, + add_list_exclusion: true, + }; + + let result = Cli::try_parse_from([ + "managarr", + "sonarr", + "delete", + "series", + "--series-id", + "1", + "--delete-files-from-disk", + "--add-list-exclusion", + ]); + + assert!(result.is_ok()); + + if let Some(Command::Sonarr(SonarrCommand::Delete(delete_command))) = result.unwrap().command + { + assert_eq!(delete_command, expected_args); + } + } + #[test] fn test_delete_tag_requires_arguments() { let result = Cli::command().try_get_matches_from(["managarr", "sonarr", "delete", "tag"]); @@ -197,7 +254,10 @@ mod tests { sonarr::delete_command_handler::{SonarrDeleteCommand, SonarrDeleteCommandHandler}, CliCommandHandler, }, - models::{sonarr_models::SonarrSerdeable, Serdeable}, + models::{ + sonarr_models::{DeleteSeriesParams, SonarrSerdeable}, + Serdeable, + }, network::{sonarr_network::SonarrEvent, MockNetworkTrait, NetworkEvent}, }; @@ -310,6 +370,40 @@ mod tests { assert!(result.is_ok()); } + #[tokio::test] + async fn test_handle_delete_series_command() { + let expected_delete_series_params = DeleteSeriesParams { + id: 1, + delete_series_files: true, + add_list_exclusion: true, + }; + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::( + SonarrEvent::DeleteSeries(Some(expected_delete_series_params)).into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Sonarr(SonarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::default())); + let delete_series_command = SonarrDeleteCommand::Series { + series_id: 1, + delete_files_from_disk: true, + add_list_exclusion: true, + }; + + let result = + SonarrDeleteCommandHandler::with(&app_arc, delete_series_command, &mut mock_network) + .handle() + .await; + + assert!(result.is_ok()); + } + #[tokio::test] async fn test_handle_delete_tag_command() { let expected_tag_id = 1; From 5ed278ec9c7c35770a52a888d1b3ed444a4f7a70 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Sun, 24 Nov 2024 11:34:09 -0700 Subject: [PATCH 096/119] feat(network): Support for fetching all Sonarr language profiles --- .../blocklist/blocklist_handler_tests.rs | 7 ++ .../library/library_handler_tests.rs | 3 + .../library/movie_details_handler.rs | 1 + .../library/movie_details_handler_tests.rs | 3 + src/models/servarr_data/sonarr/sonarr_data.rs | 2 + .../servarr_data/sonarr/sonarr_data_tests.rs | 1 + src/models/servarr_models.rs | 2 + src/models/sonarr_models.rs | 89 +++++++++++++++ src/models/sonarr_models_tests.rs | 89 ++++++++++++++- src/network/radarr_network_tests.rs | 41 +++---- src/network/sonarr_network.rs | 28 ++++- src/network/sonarr_network_tests.rs | 105 ++++++++++++------ 12 files changed, 311 insertions(+), 60 deletions(-) diff --git a/src/handlers/radarr_handlers/blocklist/blocklist_handler_tests.rs b/src/handlers/radarr_handlers/blocklist/blocklist_handler_tests.rs index d1b94b8..67d7dbf 100644 --- a/src/handlers/radarr_handlers/blocklist/blocklist_handler_tests.rs +++ b/src/handlers/radarr_handlers/blocklist/blocklist_handler_tests.rs @@ -959,6 +959,7 @@ mod tests { id: 3, source_title: "test 1".to_owned(), languages: vec![Language { + id: 1, name: "telgu".to_owned(), }], quality: QualityWrapper { @@ -967,6 +968,7 @@ mod tests { }, }, custom_formats: Some(vec![Language { + id: 2, name: "nikki".to_owned(), }]), date: DateTime::from(DateTime::parse_from_rfc3339("2024-01-10T07:28:45Z").unwrap()), @@ -979,6 +981,7 @@ mod tests { id: 2, source_title: "test 2".to_owned(), languages: vec![Language { + id: 3, name: "chinese".to_owned(), }], quality: QualityWrapper { @@ -988,9 +991,11 @@ mod tests { }, custom_formats: Some(vec![ Language { + id: 4, name: "alex".to_owned(), }, Language { + id: 5, name: "English".to_owned(), }, ]), @@ -1004,6 +1009,7 @@ mod tests { id: 1, source_title: "test 3".to_owned(), languages: vec![Language { + id: 1, name: "english".to_owned(), }], quality: QualityWrapper { @@ -1012,6 +1018,7 @@ mod tests { }, }, custom_formats: Some(vec![Language { + id: 2, name: "English".to_owned(), }]), date: DateTime::from(DateTime::parse_from_rfc3339("2024-03-10T07:28:45Z").unwrap()), diff --git a/src/handlers/radarr_handlers/library/library_handler_tests.rs b/src/handlers/radarr_handlers/library/library_handler_tests.rs index f50c804..22a4cd1 100644 --- a/src/handlers/radarr_handlers/library/library_handler_tests.rs +++ b/src/handlers/radarr_handlers/library/library_handler_tests.rs @@ -1807,6 +1807,7 @@ mod tests { id: 3, title: "test 1".into(), original_language: Language { + id: 1, name: "English".to_owned(), }, size_on_disk: 1024, @@ -1823,6 +1824,7 @@ mod tests { id: 2, title: "test 2".into(), original_language: Language { + id: 2, name: "Chinese".to_owned(), }, size_on_disk: 2048, @@ -1839,6 +1841,7 @@ mod tests { id: 1, title: "test 3".into(), original_language: Language { + id: 3, name: "Japanese".to_owned(), }, size_on_disk: 512, diff --git a/src/handlers/radarr_handlers/library/movie_details_handler.rs b/src/handlers/radarr_handlers/library/movie_details_handler.rs index 501f304..097599a 100644 --- a/src/handlers/radarr_handlers/library/movie_details_handler.rs +++ b/src/handlers/radarr_handlers/library/movie_details_handler.rs @@ -561,6 +561,7 @@ fn releases_sorting_options() -> Vec> { name: "Language", cmp_fn: Some(|a, b| { let default_language_vec = vec![Language { + id: 1, name: "_".to_owned(), }]; let language_a = &a.languages.as_ref().unwrap_or(&default_language_vec)[0]; diff --git a/src/handlers/radarr_handlers/library/movie_details_handler_tests.rs b/src/handlers/radarr_handlers/library/movie_details_handler_tests.rs index fef7f73..e789ad4 100644 --- a/src/handlers/radarr_handlers/library/movie_details_handler_tests.rs +++ b/src/handlers/radarr_handlers/library/movie_details_handler_tests.rs @@ -1987,6 +1987,7 @@ mod tests { fn test_releases_sorting_options_language() { let expected_cmp_fn: fn(&RadarrRelease, &RadarrRelease) -> Ordering = |a, b| { let default_language_vec = vec![Language { + id: 1, name: "_".to_owned(), }]; let language_a = &a.languages.as_ref().unwrap_or(&default_language_vec)[0]; @@ -2191,6 +2192,7 @@ mod tests { rejected: true, seeders: Some(Number::from(1)), languages: Some(vec![Language { + id: 1, name: "Language A".to_owned(), }]), quality: QualityWrapper { @@ -2209,6 +2211,7 @@ mod tests { rejected: false, seeders: Some(Number::from(2)), languages: Some(vec![Language { + id: 2, name: "Language B".to_owned(), }]), quality: QualityWrapper { diff --git a/src/models/servarr_data/sonarr/sonarr_data.rs b/src/models/servarr_data/sonarr/sonarr_data.rs index ea91074..73278dd 100644 --- a/src/models/servarr_data/sonarr/sonarr_data.rs +++ b/src/models/servarr_data/sonarr/sonarr_data.rs @@ -31,6 +31,7 @@ pub struct SonarrData { pub indexer_settings: Option, pub indexer_test_all_results: Option>, pub indexer_test_error: Option, + pub language_profiles_map: BiMap, pub logs: StatefulList, pub quality_profile_map: BiMap, pub queued_events: StatefulTable, @@ -67,6 +68,7 @@ impl Default for SonarrData { indexer_settings: None, indexer_test_error: None, indexer_test_all_results: None, + language_profiles_map: BiMap::new(), logs: StatefulList::default(), quality_profile_map: BiMap::new(), queued_events: StatefulTable::default(), diff --git a/src/models/servarr_data/sonarr/sonarr_data_tests.rs b/src/models/servarr_data/sonarr/sonarr_data_tests.rs index 593cbf3..11ac35a 100644 --- a/src/models/servarr_data/sonarr/sonarr_data_tests.rs +++ b/src/models/servarr_data/sonarr/sonarr_data_tests.rs @@ -59,6 +59,7 @@ mod tests { assert!(sonarr_data.indexer_settings.is_none()); assert!(sonarr_data.indexer_test_error.is_none()); assert!(sonarr_data.indexer_test_all_results.is_none()); + assert!(sonarr_data.language_profiles_map.is_empty()); assert!(sonarr_data.logs.is_empty()); assert!(sonarr_data.quality_profile_map.is_empty()); assert!(sonarr_data.queued_events.is_empty()); diff --git a/src/models/servarr_models.rs b/src/models/servarr_models.rs index 0089f5f..02134e0 100644 --- a/src/models/servarr_models.rs +++ b/src/models/servarr_models.rs @@ -136,6 +136,8 @@ pub struct IndexerField { #[derive(Serialize, Deserialize, Default, Debug, Clone, PartialEq, Eq, Ord, PartialOrd)] pub struct Language { + #[serde(deserialize_with = "super::from_i64")] + pub id: i64, pub name: String, } diff --git a/src/models/sonarr_models.rs b/src/models/sonarr_models.rs index caac288..ae10239 100644 --- a/src/models/sonarr_models.rs +++ b/src/models/sonarr_models.rs @@ -22,6 +22,28 @@ use super::{ #[path = "sonarr_models_tests.rs"] mod sonarr_models_tests; +#[derive(Default, Clone, Serialize, Debug, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct AddSeriesBody { + pub tvdb_id: i64, + pub title: String, + pub root_folder_path: String, + pub quality_profile_id: i64, + pub series_type: SeriesType, + pub season_folder: bool, + pub language_profile_id: i64, + pub tags: Vec, + pub add_options: AddSeriesOptions, +} + +#[derive(Default, Clone, Serialize, Debug, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct AddSeriesOptions { + pub monitor: SeriesMonitor, + pub search_for_cutoff_unmet_episodes: bool, + pub search_for_missing_episodes: bool, +} + #[derive(Default, Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct BlocklistItem { @@ -231,6 +253,71 @@ pub struct Series { pub seasons: Option>, } +#[derive( + Serialize, Deserialize, Default, PartialEq, Eq, Clone, Copy, Debug, EnumIter, ValueEnum, +)] +#[serde(rename_all = "camelCase")] +pub enum SeriesMonitor { + Unknown, + #[default] + All, + Future, + Missing, + Existing, + FirstSeason, + LastSeason, + LatestSeason, + Pilot, + Recent, + MonitorSpecials, + UnmonitorSpecials, + None, + Skip, +} + +impl Display for SeriesMonitor { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + let series_monitor = match self { + SeriesMonitor::Unknown => "unknown", + SeriesMonitor::All => "all", + SeriesMonitor::Future => "future", + SeriesMonitor::Missing => "missing", + SeriesMonitor::Existing => "existing", + SeriesMonitor::FirstSeason => "firstSeason", + SeriesMonitor::LastSeason => "lastSeason", + SeriesMonitor::LatestSeason => "latestSeason", + SeriesMonitor::Pilot => "pilot", + SeriesMonitor::Recent => "recent", + SeriesMonitor::MonitorSpecials => "monitorSpecials", + SeriesMonitor::UnmonitorSpecials => "unmonitorSpecials", + SeriesMonitor::None => "none", + SeriesMonitor::Skip => "skip", + }; + write!(f, "{series_monitor}") + } +} + +impl<'a> EnumDisplayStyle<'a> for SeriesMonitor { + fn to_display_str(self) -> &'a str { + match self { + SeriesMonitor::Unknown => "Unknown", + SeriesMonitor::All => "All Episodes", + SeriesMonitor::Future => "Future Episodes", + SeriesMonitor::Missing => "Missing Episodes", + SeriesMonitor::Existing => "Existing Episodes", + SeriesMonitor::FirstSeason => "Only First Season", + SeriesMonitor::LastSeason => "Only Last Season", + SeriesMonitor::LatestSeason => "Only Latest Season", + SeriesMonitor::Pilot => "Pilot Episode", + SeriesMonitor::Recent => "Recent Episodes", + SeriesMonitor::MonitorSpecials => "Only Specials", + SeriesMonitor::UnmonitorSpecials => "Not Specials", + SeriesMonitor::None => "None", + SeriesMonitor::Skip => "Skip", + } + } +} + #[derive( Serialize, Deserialize, Default, PartialEq, Eq, Clone, Copy, Debug, EnumIter, ValueEnum, )] @@ -494,6 +581,7 @@ pub enum SonarrSerdeable { IndexerSettings(IndexerSettings), Indexers(Vec), IndexerTestResults(Vec), + LanguageProfiles(Vec), LogResponse(LogResponse), QualityProfiles(Vec), QueueEvents(Vec), @@ -535,6 +623,7 @@ serde_enum_from!( IndexerSettings(IndexerSettings), Indexers(Vec), IndexerTestResults(Vec), + LanguageProfiles(Vec), LogResponse(LogResponse), QualityProfiles(Vec), QueueEvents(Vec), diff --git a/src/models/sonarr_models_tests.rs b/src/models/sonarr_models_tests.rs index 0a6aff3..9171342 100644 --- a/src/models/sonarr_models_tests.rs +++ b/src/models/sonarr_models_tests.rs @@ -6,13 +6,13 @@ mod tests { use crate::models::{ radarr_models::IndexerTestResult, servarr_models::{ - DiskSpace, HostConfig, Indexer, Log, LogResponse, QualityProfile, QueueEvent, RootFolder, - SecurityConfig, Tag, Update, + DiskSpace, HostConfig, Indexer, Language, Log, LogResponse, QualityProfile, QueueEvent, + RootFolder, SecurityConfig, Tag, Update, }, sonarr_models::{ BlocklistItem, BlocklistResponse, DownloadRecord, DownloadsResponse, Episode, - IndexerSettings, Series, SeriesStatus, SeriesType, SonarrHistoryEventType, SonarrHistoryItem, - SonarrRelease, SonarrSerdeable, SonarrTask, SonarrTaskName, SystemStatus, + IndexerSettings, Series, SeriesMonitor, SeriesStatus, SeriesType, SonarrHistoryEventType, + SonarrHistoryItem, SonarrRelease, SonarrSerdeable, SonarrTask, SonarrTaskName, SystemStatus, }, EnumDisplayStyle, Serdeable, }; @@ -28,6 +28,66 @@ mod tests { assert_str_eq!(episode.to_string(), "Test Title"); } + #[test] + fn test_series_monitor_display() { + assert_str_eq!(SeriesMonitor::Unknown.to_string(), "unknown"); + assert_str_eq!(SeriesMonitor::All.to_string(), "all"); + assert_str_eq!(SeriesMonitor::Future.to_string(), "future"); + assert_str_eq!(SeriesMonitor::Missing.to_string(), "missing"); + assert_str_eq!(SeriesMonitor::Existing.to_string(), "existing"); + assert_str_eq!(SeriesMonitor::FirstSeason.to_string(), "firstSeason"); + assert_str_eq!(SeriesMonitor::LastSeason.to_string(), "lastSeason"); + assert_str_eq!(SeriesMonitor::LatestSeason.to_string(), "latestSeason"); + assert_str_eq!(SeriesMonitor::Pilot.to_string(), "pilot"); + assert_str_eq!(SeriesMonitor::Recent.to_string(), "recent"); + assert_str_eq!( + SeriesMonitor::MonitorSpecials.to_string(), + "monitorSpecials" + ); + assert_str_eq!( + SeriesMonitor::UnmonitorSpecials.to_string(), + "unmonitorSpecials" + ); + assert_str_eq!(SeriesMonitor::None.to_string(), "none"); + assert_str_eq!(SeriesMonitor::Skip.to_string(), "skip"); + } + + #[test] + fn test_series_monitor_to_display_str() { + assert_str_eq!(SeriesMonitor::Unknown.to_display_str(), "Unknown"); + assert_str_eq!(SeriesMonitor::All.to_display_str(), "All Episodes"); + assert_str_eq!(SeriesMonitor::Future.to_display_str(), "Future Episodes"); + assert_str_eq!(SeriesMonitor::Missing.to_display_str(), "Missing Episodes"); + assert_str_eq!( + SeriesMonitor::Existing.to_display_str(), + "Existing Episodes" + ); + assert_str_eq!( + SeriesMonitor::FirstSeason.to_display_str(), + "Only First Season" + ); + assert_str_eq!( + SeriesMonitor::LastSeason.to_display_str(), + "Only Last Season" + ); + assert_str_eq!( + SeriesMonitor::LatestSeason.to_display_str(), + "Only Latest Season" + ); + assert_str_eq!(SeriesMonitor::Pilot.to_display_str(), "Pilot Episode"); + assert_str_eq!(SeriesMonitor::Recent.to_display_str(), "Recent Episodes"); + assert_str_eq!( + SeriesMonitor::MonitorSpecials.to_display_str(), + "Only Specials" + ); + assert_str_eq!( + SeriesMonitor::UnmonitorSpecials.to_display_str(), + "Not Specials" + ); + assert_str_eq!(SeriesMonitor::None.to_display_str(), "None"); + assert_str_eq!(SeriesMonitor::Skip.to_display_str(), "Skip"); + } + #[test] fn test_series_status_display() { assert_str_eq!(SeriesStatus::Continuing.to_string(), "continuing"); @@ -312,6 +372,27 @@ mod tests { assert_eq!(sonarr_serdeable, SonarrSerdeable::DiskSpaces(disk_spaces)); } + #[test] + fn test_sonarr_serdeable_from_language_profiles() { + let language_profiles = vec![ + Language { + id: 1, + name: "English".to_owned(), + }, + Language { + id: 2, + name: "Japanese".to_owned(), + }, + ]; + + let sonarr_serdeable: SonarrSerdeable = language_profiles.clone().into(); + + assert_eq!( + sonarr_serdeable, + SonarrSerdeable::LanguageProfiles(language_profiles) + ); + } + #[test] fn test_sonarr_serdeable_from_log_response() { let log_response = LogResponse { diff --git a/src/network/radarr_network_tests.rs b/src/network/radarr_network_tests.rs index f695f1a..7d8b442 100644 --- a/src/network/radarr_network_tests.rs +++ b/src/network/radarr_network_tests.rs @@ -34,6 +34,7 @@ mod test { "title": "Test", "tmdbId": 1234, "originalLanguage": { + "id": 1, "name": "English" }, "sizeOnDisk": 3543348019, @@ -499,7 +500,7 @@ mod test { "rejections": [ "Unknown quality profile", "Release is already mapped" ], "seeders": 2, "leechers": 1, - "languages": [ { "name": "English" } ], + "languages": [ { "id": 1, "name": "English" } ], "quality": { "quality": { "name": "HD - 1080p" }} }]); let (async_server, app_arc, _server) = mock_servarr_api( @@ -559,7 +560,7 @@ mod test { "rejections": [ "Unknown quality profile", "Release is already mapped" ], "seeders": 2, "leechers": 1, - "languages": [ { "name": "English" } ], + "languages": [ { "id": 1, "name": "English" } ], "quality": { "quality": { "name": "HD - 1080p" }} }]); let (async_server, app_arc, _server) = mock_servarr_api( @@ -607,7 +608,7 @@ mod test { let add_movie_search_result_json = json!([{ "tmdbId": 1234, "title": "Test", - "originalLanguage": { "name": "English" }, + "originalLanguage": { "id": 1, "name": "English" }, "status": "released", "overview": "New movie blah blah blah", "genres": ["cool", "family", "fun"], @@ -672,7 +673,7 @@ mod test { let add_movie_search_result_json = json!([{ "tmdbId": 1234, "title": "Test", - "originalLanguage": { "name": "English" }, + "originalLanguage": { "id": 1, "name": "English" }, "status": "released", "overview": "New movie blah blah blah", "genres": ["cool", "family", "fun"], @@ -1474,6 +1475,7 @@ mod test { "id": 1, "title": "Test", "originalLanguage": { + "id": 1, "name": "English" }, "sizeOnDisk": 0, @@ -1559,7 +1561,7 @@ mod test { let movie_history_item_json = json!([{ "sourceTitle": "Test", "quality": { "quality": { "name": "HD - 1080p" }}, - "languages": [ { "name": "English" } ], + "languages": [ { "id": 1, "name": "English" } ], "date": "2022-12-30T07:37:56Z", "eventType": "grabbed" }]); @@ -1613,7 +1615,7 @@ mod test { let movie_history_item_json = json!([{ "sourceTitle": "Test", "quality": { "quality": { "name": "HD - 1080p" }}, - "languages": [ { "name": "English" } ], + "languages": [ { "id": 1, "name": "English" } ], "date": "2022-12-30T07:37:56Z", "eventType": "grabbed" }]); @@ -1646,7 +1648,7 @@ mod test { let movie_history_item_json = json!([{ "sourceTitle": "Test", "quality": { "quality": { "name": "HD - 1080p" }}, - "languages": [ { "name": "English" } ], + "languages": [ { "id": 1, "name": "English" } ], "date": "2022-12-30T07:37:56Z", "eventType": "grabbed" }]); @@ -1697,9 +1699,9 @@ mod test { "id": 123, "movieId": 1007, "sourceTitle": "z movie", - "languages": [{"name": "English"}], + "languages": [{"id": 1, "name": "English"}], "quality": {"quality": {"name": "HD - 1080p"}}, - "customFormats": [{"name": "English"}], + "customFormats": [{"id": 1, "name": "English"}], "date": "2024-02-10T07:28:45Z", "protocol": "usenet", "indexer": "DrunkenSlug (Prowlarr)", @@ -1708,7 +1710,7 @@ mod test { "id": 1007, "title": "z movie", "tmdbId": 1234, - "originalLanguage": {"name": "English"}, + "originalLanguage": {"id": 1, "name": "English"}, "sizeOnDisk": 3543348019i64, "status": "Downloaded", "overview": "Blah blah blah", @@ -1733,9 +1735,9 @@ mod test { "id": 456, "movieId": 2001, "sourceTitle": "A Movie", - "languages": [{"name": "English"}], + "languages": [{"id": 1, "name": "English"}], "quality": {"quality": {"name": "HD - 1080p"}}, - "customFormats": [{"name": "English"}], + "customFormats": [{"id": 1, "name": "English"}], "date": "2024-02-10T07:28:45Z", "protocol": "usenet", "indexer": "DrunkenSlug (Prowlarr)", @@ -1744,7 +1746,7 @@ mod test { "id": 2001, "title": "A Movie", "tmdbId": 1234, - "originalLanguage": {"name": "English"}, + "originalLanguage": {"id": 1, "name": "English"}, "sizeOnDisk": 3543348019i64, "status": "Downloaded", "overview": "Blah blah blah", @@ -1841,9 +1843,9 @@ mod test { "id": 123, "movieId": 1007, "sourceTitle": "z movie", - "languages": [{"name": "English"}], + "languages": [{"id": 1, "name": "English"}], "quality": {"quality": {"name": "HD - 1080p"}}, - "customFormats": [{"name": "English"}], + "customFormats": [{"id": 1, "name": "English"}], "date": "2024-02-10T07:28:45Z", "protocol": "usenet", "indexer": "DrunkenSlug (Prowlarr)", @@ -1852,7 +1854,7 @@ mod test { "id": 1007, "title": "z movie", "tmdbId": 1234, - "originalLanguage": {"name": "English"}, + "originalLanguage": {"id": 1, "name": "English"}, "sizeOnDisk": 3543348019i64, "status": "Downloaded", "overview": "Blah blah blah", @@ -1877,9 +1879,9 @@ mod test { "id": 456, "movieId": 2001, "sourceTitle": "A Movie", - "languages": [{"name": "English"}], + "languages": [{"id": 1, "name": "English"}], "quality": {"quality": {"name": "HD - 1080p"}}, - "customFormats": [{"name": "English"}], + "customFormats": [{"id": 1, "name": "English"}], "date": "2024-02-10T07:28:45Z", "protocol": "usenet", "indexer": "DrunkenSlug (Prowlarr)", @@ -1888,7 +1890,7 @@ mod test { "id": 2001, "title": "A Movie", "tmdbId": 1234, - "originalLanguage": {"name": "English"}, + "originalLanguage": {"id": 1, "name": "English"}, "sizeOnDisk": 3543348019i64, "status": "Downloaded", "overview": "Blah blah blah", @@ -5076,6 +5078,7 @@ mod test { fn language() -> Language { Language { + id: 1, name: "English".to_owned(), } } diff --git a/src/network/sonarr_network.rs b/src/network/sonarr_network.rs index 15a221b..fac78d7 100644 --- a/src/network/sonarr_network.rs +++ b/src/network/sonarr_network.rs @@ -14,8 +14,8 @@ use crate::{ }, }, servarr_models::{ - AddRootFolderBody, CommandBody, DiskSpace, HostConfig, Indexer, LogResponse, QualityProfile, - QueueEvent, RootFolder, SecurityConfig, Tag, Update, + AddRootFolderBody, CommandBody, DiskSpace, HostConfig, Indexer, Language, LogResponse, + QualityProfile, QueueEvent, RootFolder, SecurityConfig, Tag, Update, }, sonarr_models::{ BlocklistResponse, DeleteSeriesParams, DownloadRecord, DownloadsResponse, Episode, @@ -56,6 +56,7 @@ pub enum SonarrEvent { GetEpisodeDetails(Option), GetEpisodes(Option), GetEpisodeHistory(Option), + GetLanguageProfiles, GetLogs(Option), GetDiskSpace, GetQualityProfiles, @@ -98,6 +99,7 @@ impl NetworkResource for SonarrEvent { SonarrEvent::GetHistory(_) | SonarrEvent::GetEpisodeHistory(_) => "/history", SonarrEvent::GetHostConfig | SonarrEvent::GetSecurityConfig => "/config/host", SonarrEvent::GetIndexers | SonarrEvent::DeleteIndexer(_) => "/indexer", + SonarrEvent::GetLanguageProfiles => "/languageprofile", SonarrEvent::GetLogs(_) => "/log", SonarrEvent::GetDiskSpace => "/diskspace", SonarrEvent::GetQualityProfiles => "/qualityprofile", @@ -203,6 +205,10 @@ impl<'a, 'b> Network<'a, 'b> { .await .map(SonarrSerdeable::from), SonarrEvent::GetIndexers => self.get_sonarr_indexers().await.map(SonarrSerdeable::from), + SonarrEvent::GetLanguageProfiles => self + .get_sonarr_language_profiles() + .await + .map(SonarrSerdeable::from), SonarrEvent::GetLogs(events) => self .get_sonarr_logs(events) .await @@ -951,6 +957,24 @@ impl<'a, 'b> Network<'a, 'b> { .await } + async fn get_sonarr_language_profiles(&mut self) -> Result> { + info!("Fetching Sonarr language profiles"); + let event = SonarrEvent::GetLanguageProfiles; + + let request_props = self + .request_props_from(event, RequestMethod::Get, None::<()>, None, None) + .await; + + self + .handle_request::<(), Vec>(request_props, |language_profiles_vec, mut app| { + app.data.sonarr_data.language_profiles_map = language_profiles_vec + .into_iter() + .map(|language| (language.id, language.name)) + .collect(); + }) + .await + } + async fn get_sonarr_logs(&mut self, events: Option) -> Result { info!("Fetching Sonarr logs"); let event = SonarrEvent::GetLogs(events); diff --git a/src/network/sonarr_network_tests.rs b/src/network/sonarr_network_tests.rs index b7d0625..f7a0b63 100644 --- a/src/network/sonarr_network_tests.rs +++ b/src/network/sonarr_network_tests.rs @@ -104,7 +104,7 @@ mod test { "path": "/nfs/tv/series/season 1/episode 1.mkv", "size": 3543348019, "dateAdded": "2024-02-10T07:28:45Z", - "language": { "name": "English" }, + "language": { "id": 1, "name": "English" }, "quality": { "quality": { "name": "Bluray-1080p" } }, "mediaInfo": { "audioBitrate": 0, @@ -235,6 +235,7 @@ mod test { #[case(SonarrEvent::GetBlocklist, "/blocklist?page=1&pageSize=10000")] #[case(SonarrEvent::GetDiskSpace, "/diskspace")] #[case(SonarrEvent::GetSeriesHistory(None), "/history/series")] + #[case(SonarrEvent::GetLanguageProfiles, "/languageprofile")] #[case(SonarrEvent::GetLogs(Some(500)), "/log")] #[case(SonarrEvent::GetQualityProfiles, "/qualityprofile")] #[case(SonarrEvent::GetStatus, "/system/status")] @@ -1274,7 +1275,7 @@ mod test { "sourceTitle": "z episode", "episodeId": 1007, "quality": { "quality": { "name": "Bluray-1080p" } }, - "language": { "name": "English" }, + "language": { "id": 1, "name": "English" }, "date": "2024-02-10T07:28:45Z", "eventType": "grabbed", "data": { @@ -1287,7 +1288,7 @@ mod test { "sourceTitle": "A Episode", "episodeId": 2001, "quality": { "quality": { "name": "Bluray-1080p" } }, - "language": { "name": "English" }, + "language": { "id": 1, "name": "English" }, "date": "2024-02-10T07:28:45Z", "eventType": "grabbed", "data": { @@ -1366,7 +1367,7 @@ mod test { "sourceTitle": "z episode", "episodeId": 1007, "quality": { "quality": { "name": "Bluray-1080p" } }, - "language": { "name": "English" }, + "language": { "id": 1, "name": "English" }, "date": "2024-02-10T07:28:45Z", "eventType": "grabbed", "data": { @@ -1379,7 +1380,7 @@ mod test { "sourceTitle": "A Episode", "episodeId": 2001, "quality": { "quality": { "name": "Bluray-1080p" } }, - "language": { "name": "English" }, + "language": { "id": 1, "name": "English" }, "date": "2024-02-10T07:28:45Z", "eventType": "grabbed", "data": { @@ -1437,7 +1438,7 @@ mod test { "sourceTitle": "z episode", "episodeId": 1007, "quality": { "quality": { "name": "Bluray-1080p" } }, - "language": { "name": "English" }, + "language": { "id": 1, "name": "English" }, "date": "2024-02-10T07:28:45Z", "eventType": "grabbed", "data": { @@ -1450,7 +1451,7 @@ mod test { "sourceTitle": "A Episode", "episodeId": 2001, "quality": { "quality": { "name": "Bluray-1080p" } }, - "language": { "name": "English" }, + "language": { "id": 1, "name": "English" }, "date": "2024-02-10T07:28:45Z", "eventType": "grabbed", "data": { @@ -1763,7 +1764,7 @@ mod test { "sourceTitle": "z episode", "episodeId": 1007, "quality": { "quality": { "name": "Bluray-1080p" } }, - "language": { "name": "English" }, + "language": { "id": 1, "name": "English" }, "date": "2024-02-10T07:28:45Z", "eventType": "grabbed", "data": { @@ -1776,7 +1777,7 @@ mod test { "sourceTitle": "A Episode", "episodeId": 2001, "quality": { "quality": { "name": "Bluray-1080p" } }, - "language": { "name": "English" }, + "language": { "id": 1, "name": "English" }, "date": "2024-02-10T07:28:45Z", "eventType": "grabbed", "data": { @@ -1893,7 +1894,7 @@ mod test { "sourceTitle": "z episode", "episodeId": 1007, "quality": { "quality": { "name": "Bluray-1080p" } }, - "language": { "name": "English" }, + "language": { "id": 1, "name": "English" }, "date": "2024-02-10T07:28:45Z", "eventType": "grabbed", "data": { @@ -1906,7 +1907,7 @@ mod test { "sourceTitle": "A Episode", "episodeId": 2001, "quality": { "quality": { "name": "Bluray-1080p" } }, - "language": { "name": "English" }, + "language": { "id": 1, "name": "English" }, "date": "2024-02-10T07:28:45Z", "eventType": "grabbed", "data": { @@ -2023,7 +2024,7 @@ mod test { "sourceTitle": "z episode", "episodeId": 1007, "quality": { "quality": { "name": "Bluray-1080p" } }, - "language": { "name": "English" }, + "language": { "id": 1, "name": "English" }, "date": "2024-02-10T07:28:45Z", "eventType": "grabbed", "data": { @@ -2036,7 +2037,7 @@ mod test { "sourceTitle": "A Episode", "episodeId": 2001, "quality": { "quality": { "name": "Bluray-1080p" } }, - "language": { "name": "English" }, + "language": { "id": 1, "name": "English" }, "date": "2024-02-10T07:28:45Z", "eventType": "grabbed", "data": { @@ -2131,7 +2132,7 @@ mod test { "sourceTitle": "z episode", "episodeId": 1007, "quality": { "quality": { "name": "Bluray-1080p" } }, - "language": { "name": "English" }, + "language": { "id": 1, "name": "English" }, "date": "2024-02-10T07:28:45Z", "eventType": "grabbed", "data": { @@ -2144,7 +2145,7 @@ mod test { "sourceTitle": "A Episode", "episodeId": 2001, "quality": { "quality": { "name": "Bluray-1080p" } }, - "language": { "name": "English" }, + "language": { "id": 1, "name": "English" }, "date": "2024-02-10T07:28:45Z", "eventType": "grabbed", "data": { @@ -2290,6 +2291,39 @@ mod test { .unwrap(); } + #[tokio::test] + async fn test_handle_get_sonarr_language_profiles_event() { + let language_profiles_json = json!([{ + "id": 2222, + "name": "English" + }]); + let response: Vec = serde_json::from_value(language_profiles_json.clone()).unwrap(); + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(language_profiles_json), + None, + SonarrEvent::GetLanguageProfiles, + None, + None, + ) + .await; + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + if let SonarrSerdeable::LanguageProfiles(language_profiles) = network + .handle_sonarr_event(SonarrEvent::GetLanguageProfiles) + .await + .unwrap() + { + async_server.assert_async().await; + assert_eq!( + app_arc.lock().await.data.sonarr_data.language_profiles_map, + BiMap::from_iter([(2222i64, "English".to_owned())]) + ); + assert_eq!(language_profiles, response); + } + } + #[tokio::test] async fn test_handle_get_sonarr_logs_event() { let expected_logs = vec![ @@ -2560,7 +2594,7 @@ mod test { "rejections": [ "Unknown quality profile", "Release is already mapped" ], "seeders": 2, "leechers": 1, - "languages": [ { "name": "English" } ], + "languages": [ { "id": 1, "name": "English" } ], "quality": { "quality": { "name": "Bluray-1080p" }} }]); let (async_server, app_arc, _server) = mock_servarr_api( @@ -2627,7 +2661,7 @@ mod test { "rejections": [ "Unknown quality profile", "Release is already mapped" ], "seeders": 2, "leechers": 1, - "languages": [ { "name": "English" } ], + "languages": [ { "id": 1, "name": "English" } ], "quality": { "quality": { "name": "Bluray-1080p" }} }]); let (async_server, app_arc, _server) = mock_servarr_api( @@ -2686,7 +2720,7 @@ mod test { "rejections": [ "Unknown quality profile", "Release is already mapped" ], "seeders": 2, "leechers": 1, - "languages": [ { "name": "English" } ], + "languages": [ { "id": 1, "name": "English" } ], "quality": { "quality": { "name": "Bluray-1080p" }} }]); let (_async_server, app_arc, _server) = mock_servarr_api( @@ -2721,7 +2755,7 @@ mod test { "rejections": [ "Unknown quality profile", "Release is already mapped" ], "seeders": 2, "leechers": 1, - "languages": [ { "name": "English" } ], + "languages": [ { "id": 1, "name": "English" } ], "quality": { "quality": { "name": "Bluray-1080p" }} }]); let (async_server, app_arc, _server) = mock_servarr_api( @@ -2789,7 +2823,7 @@ mod test { "rejections": [ "Unknown quality profile", "Release is already mapped" ], "seeders": 2, "leechers": 1, - "languages": [ { "name": "English" } ], + "languages": [ { "id": 1, "name": "English" } ], "quality": { "quality": { "name": "Bluray-1080p" }}, "fullSeason": true }, @@ -2805,7 +2839,7 @@ mod test { "rejections": [ "Unknown quality profile", "Release is already mapped" ], "seeders": 2, "leechers": 1, - "languages": [ { "name": "English" } ], + "languages": [ { "id": 1, "name": "English" } ], "quality": { "quality": { "name": "Bluray-1080p" }}, } ]); @@ -2889,7 +2923,7 @@ mod test { "rejections": [ "Unknown quality profile", "Release is already mapped" ], "seeders": 2, "leechers": 1, - "languages": [ { "name": "English" } ], + "languages": [ { "id": 1, "name": "English" } ], "quality": { "quality": { "name": "Bluray-1080p" }}, "fullSeason": true }, @@ -2905,7 +2939,7 @@ mod test { "rejections": [ "Unknown quality profile", "Release is already mapped" ], "seeders": 2, "leechers": 1, - "languages": [ { "name": "English" } ], + "languages": [ { "id": 1, "name": "English" } ], "quality": { "quality": { "name": "Bluray-1080p" }}, } ]); @@ -2975,7 +3009,7 @@ mod test { "rejections": [ "Unknown quality profile", "Release is already mapped" ], "seeders": 2, "leechers": 1, - "languages": [ { "name": "English" } ], + "languages": [ { "id": 1, "name": "English" } ], "quality": { "quality": { "name": "Bluray-1080p" }}, "fullSeason": true }, @@ -2991,7 +3025,7 @@ mod test { "rejections": [ "Unknown quality profile", "Release is already mapped" ], "seeders": 2, "leechers": 1, - "languages": [ { "name": "English" } ], + "languages": [ { "id": 1, "name": "English" } ], "quality": { "quality": { "name": "Bluray-1080p" }}, } ]); @@ -3075,7 +3109,7 @@ mod test { "rejections": [ "Unknown quality profile", "Release is already mapped" ], "seeders": 2, "leechers": 1, - "languages": [ { "name": "English" } ], + "languages": [ { "id": 1, "name": "English" } ], "quality": { "quality": { "name": "Bluray-1080p" }}, "fullSeason": true }, @@ -3091,7 +3125,7 @@ mod test { "rejections": [ "Unknown quality profile", "Release is already mapped" ], "seeders": 2, "leechers": 1, - "languages": [ { "name": "English" } ], + "languages": [ { "id": 1, "name": "English" } ], "quality": { "quality": { "name": "Bluray-1080p" }}, } ]); @@ -3318,7 +3352,7 @@ mod test { "sourceTitle": "z episode", "episodeId": 1007, "quality": { "quality": { "name": "Bluray-1080p" } }, - "language": { "name": "English" }, + "language": { "id": 1, "name": "English" }, "date": "2024-02-10T07:28:45Z", "eventType": "grabbed", "data": { @@ -3331,7 +3365,7 @@ mod test { "sourceTitle": "A Episode", "episodeId": 2001, "quality": { "quality": { "name": "Bluray-1080p" } }, - "language": { "name": "English" }, + "language": { "id": 1, "name": "English" }, "date": "2024-02-10T07:28:45Z", "eventType": "grabbed", "data": { @@ -3440,7 +3474,7 @@ mod test { "sourceTitle": "z episode", "episodeId": 1007, "quality": { "quality": { "name": "Bluray-1080p" } }, - "language": { "name": "English" }, + "language": { "id": 1, "name": "English" }, "date": "2024-02-10T07:28:45Z", "eventType": "grabbed", "data": { @@ -3453,7 +3487,7 @@ mod test { "sourceTitle": "A Episode", "episodeId": 2001, "quality": { "quality": { "name": "Bluray-1080p" } }, - "language": { "name": "English" }, + "language": { "id": 1, "name": "English" }, "date": "2024-02-10T07:28:45Z", "eventType": "grabbed", "data": { @@ -3546,7 +3580,7 @@ mod test { "sourceTitle": "z episode", "episodeId": 1007, "quality": { "quality": { "name": "Bluray-1080p" } }, - "language": { "name": "English" }, + "language": { "id": 1, "name": "English" }, "date": "2024-02-10T07:28:45Z", "eventType": "grabbed", "data": { @@ -3559,7 +3593,7 @@ mod test { "sourceTitle": "A Episode", "episodeId": 2001, "quality": { "quality": { "name": "Bluray-1080p" } }, - "language": { "name": "English" }, + "language": { "id": 1, "name": "English" }, "date": "2024-02-10T07:28:45Z", "eventType": "grabbed", "data": { @@ -3648,7 +3682,7 @@ mod test { "sourceTitle": "z episode", "episodeId": 1007, "quality": { "quality": { "name": "Bluray-1080p" } }, - "language": { "name": "English" }, + "language": { "id": 1, "name": "English" }, "date": "2024-02-10T07:28:45Z", "eventType": "grabbed", "data": { @@ -3661,7 +3695,7 @@ mod test { "sourceTitle": "A Episode", "episodeId": 2001, "quality": { "quality": { "name": "Bluray-1080p" } }, - "language": { "name": "English" }, + "language": { "id": 1, "name": "English" }, "date": "2024-02-10T07:28:45Z", "eventType": "grabbed", "data": { @@ -5175,6 +5209,7 @@ mod test { fn language() -> Language { Language { + id: 1, name: "English".to_owned(), } } From c98828aec7cadf51f2202b426858c8c5911b6731 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Sun, 24 Nov 2024 11:36:14 -0700 Subject: [PATCH 097/119] feat(cli): Support for fetching all sonarr language profiles --- src/cli/sonarr/list_command_handler.rs | 9 +++++++++ src/cli/sonarr/list_command_handler_tests.rs | 4 +++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/src/cli/sonarr/list_command_handler.rs b/src/cli/sonarr/list_command_handler.rs index c491b26..cf92a36 100644 --- a/src/cli/sonarr/list_command_handler.rs +++ b/src/cli/sonarr/list_command_handler.rs @@ -49,6 +49,8 @@ pub enum SonarrListCommand { }, #[command(about = "List all Sonarr indexers")] Indexers, + #[command(about = "List all Sonarr language profiles")] + LanguageProfiles, #[command(about = "Fetch Sonarr logs")] Logs { #[arg(long, help = "How many log events to fetch", default_value_t = 500)] @@ -160,6 +162,13 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, SonarrListCommand> for SonarrListCommandH .await?; serde_json::to_string_pretty(&resp)? } + SonarrListCommand::LanguageProfiles => { + let resp = self + .network + .handle_network_event(SonarrEvent::GetLanguageProfiles.into()) + .await?; + serde_json::to_string_pretty(&resp)? + } SonarrListCommand::Logs { events, output_in_log_format, diff --git a/src/cli/sonarr/list_command_handler_tests.rs b/src/cli/sonarr/list_command_handler_tests.rs index 0382f60..c6c8f04 100644 --- a/src/cli/sonarr/list_command_handler_tests.rs +++ b/src/cli/sonarr/list_command_handler_tests.rs @@ -36,7 +36,8 @@ mod tests { "root-folders", "tags", "tasks", - "updates" + "updates", + "language-profiles" )] subcommand: &str, ) { @@ -211,6 +212,7 @@ mod tests { #[case(SonarrListCommand::Tags, SonarrEvent::GetTags)] #[case(SonarrListCommand::Tasks, SonarrEvent::GetTasks)] #[case(SonarrListCommand::Updates, SonarrEvent::GetUpdates)] + #[case(SonarrListCommand::LanguageProfiles, SonarrEvent::GetLanguageProfiles)] #[tokio::test] async fn test_handle_list_command( #[case] list_command: SonarrListCommand, From 5ba3f2b1ba53e720a9f8b2405bcf2db625bf4886 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Sun, 24 Nov 2024 13:18:02 -0700 Subject: [PATCH 098/119] feat(network): Support for adding a new series to Sonarr --- src/models/servarr_data/sonarr/modals.rs | 63 +++- .../servarr_data/sonarr/modals_tests.rs | 58 +++ src/models/servarr_data/sonarr/sonarr_data.rs | 11 +- .../servarr_data/sonarr/sonarr_data_tests.rs | 3 + src/models/sonarr_models.rs | 35 +- src/network/radarr_network.rs | 8 +- src/network/radarr_network_tests.rs | 8 +- src/network/sonarr_network.rs | 152 +++++++- src/network/sonarr_network_tests.rs | 356 +++++++++++++++++- 9 files changed, 668 insertions(+), 26 deletions(-) create mode 100644 src/models/servarr_data/sonarr/modals_tests.rs diff --git a/src/models/servarr_data/sonarr/modals.rs b/src/models/servarr_data/sonarr/modals.rs index 4abf22c..c5a128d 100644 --- a/src/models/servarr_data/sonarr/modals.rs +++ b/src/models/servarr_data/sonarr/modals.rs @@ -1,9 +1,68 @@ +use strum::IntoEnumIterator; + use crate::models::{ - sonarr_models::{Episode, SonarrHistoryItem, SonarrRelease}, + servarr_models::RootFolder, + sonarr_models::{Episode, SeriesMonitor, SeriesType, SonarrHistoryItem, SonarrRelease}, + stateful_list::StatefulList, stateful_table::StatefulTable, - ScrollableText, + HorizontallyScrollableText, ScrollableText, }; +use super::sonarr_data::SonarrData; + +#[cfg(test)] +#[path = "modals_tests.rs"] +mod modals_tests; + +#[derive(Default)] +pub struct AddSeriesModal { + pub root_folder_list: StatefulList, + pub monitor_list: StatefulList, + pub quality_profile_list: StatefulList, + pub language_profile_list: StatefulList, + pub series_type_list: StatefulList, + pub use_season_folder: bool, + pub tags: HorizontallyScrollableText, +} + +impl From<&SonarrData> for AddSeriesModal { + fn from(sonarr_data: &SonarrData) -> AddSeriesModal { + let mut add_series_modal = AddSeriesModal { + use_season_folder: true, + ..AddSeriesModal::default() + }; + add_series_modal + .monitor_list + .set_items(Vec::from_iter(SeriesMonitor::iter())); + add_series_modal + .series_type_list + .set_items(Vec::from_iter(SeriesType::iter())); + let mut quality_profile_names: Vec = sonarr_data + .quality_profile_map + .right_values() + .cloned() + .collect(); + quality_profile_names.sort(); + add_series_modal + .quality_profile_list + .set_items(quality_profile_names); + let mut language_profile_names: Vec = sonarr_data + .language_profiles_map + .right_values() + .cloned() + .collect(); + language_profile_names.sort(); + add_series_modal + .language_profile_list + .set_items(language_profile_names); + add_series_modal + .root_folder_list + .set_items(sonarr_data.root_folders.items.to_vec()); + + add_series_modal + } +} + #[derive(Default)] pub struct EpisodeDetailsModal { pub episode_details: ScrollableText, diff --git a/src/models/servarr_data/sonarr/modals_tests.rs b/src/models/servarr_data/sonarr/modals_tests.rs new file mode 100644 index 0000000..fc25384 --- /dev/null +++ b/src/models/servarr_data/sonarr/modals_tests.rs @@ -0,0 +1,58 @@ +#[cfg(test)] +mod tests { + use bimap::BiMap; + use strum::IntoEnumIterator; + + use crate::models::{ + servarr_data::sonarr::{modals::AddSeriesModal, sonarr_data::SonarrData}, + servarr_models::RootFolder, + sonarr_models::{SeriesMonitor, SeriesType}, + }; + + #[test] + fn test_add_series_modal_from_sonarr_data() { + let root_folder = RootFolder { + id: 1, + path: "/nfs".to_owned(), + accessible: true, + free_space: 219902325555200, + unmapped_folders: None, + }; + let mut sonarr_data = SonarrData { + quality_profile_map: BiMap::from_iter([ + (2222, "HD - 1080p".to_owned()), + (1111, "Any".to_owned()), + ]), + language_profiles_map: BiMap::from_iter([ + (2222, "English".to_owned()), + (1111, "Any".to_owned()), + ]), + ..SonarrData::default() + }; + sonarr_data + .root_folders + .set_items(vec![root_folder.clone()]); + + let add_series_modal = AddSeriesModal::from(&sonarr_data); + + assert_eq!( + add_series_modal.monitor_list.items, + Vec::from_iter(SeriesMonitor::iter()) + ); + assert_eq!( + add_series_modal.series_type_list.items, + Vec::from_iter(SeriesType::iter()) + ); + assert_eq!( + add_series_modal.quality_profile_list.items, + vec!["Any".to_owned(), "HD - 1080p".to_owned()] + ); + assert_eq!( + add_series_modal.language_profile_list.items, + vec!["Any".to_owned(), "English".to_owned()] + ); + assert_eq!(add_series_modal.root_folder_list.items, vec![root_folder]); + assert!(add_series_modal.tags.text.is_empty()); + assert!(add_series_modal.use_season_folder); + } +} diff --git a/src/models/servarr_data/sonarr/sonarr_data.rs b/src/models/servarr_data/sonarr/sonarr_data.rs index 73278dd..4af41d6 100644 --- a/src/models/servarr_data/sonarr/sonarr_data.rs +++ b/src/models/servarr_data/sonarr/sonarr_data.rs @@ -6,14 +6,15 @@ use crate::models::{ servarr_data::modals::IndexerTestResultModalItem, servarr_models::{DiskSpace, Indexer, QueueEvent, RootFolder}, sonarr_models::{ - BlocklistItem, DownloadRecord, IndexerSettings, Season, Series, SonarrHistoryItem, SonarrTask, + AddSeriesSearchResult, BlocklistItem, DownloadRecord, IndexerSettings, Season, Series, + SonarrHistoryItem, SonarrTask, }, stateful_list::StatefulList, stateful_table::StatefulTable, HorizontallyScrollableText, Route, ScrollableText, }; -use super::modals::SeasonDetailsModal; +use super::modals::{AddSeriesModal, SeasonDetailsModal}; #[cfg(test)] #[path = "sonarr_data_tests.rs"] @@ -21,6 +22,9 @@ mod sonarr_data_tests; pub struct SonarrData { pub add_list_exclusion: bool, + pub add_searched_series: Option>, + pub add_series_modal: Option, + pub add_series_search: Option, pub blocklist: StatefulTable, pub delete_series_files: bool, pub downloads: StatefulTable, @@ -58,6 +62,9 @@ impl Default for SonarrData { fn default() -> SonarrData { SonarrData { add_list_exclusion: false, + add_searched_series: None, + add_series_search: None, + add_series_modal: None, blocklist: StatefulTable::default(), downloads: StatefulTable::default(), delete_series_files: false, diff --git a/src/models/servarr_data/sonarr/sonarr_data_tests.rs b/src/models/servarr_data/sonarr/sonarr_data_tests.rs index 11ac35a..a698b8f 100644 --- a/src/models/servarr_data/sonarr/sonarr_data_tests.rs +++ b/src/models/servarr_data/sonarr/sonarr_data_tests.rs @@ -49,6 +49,9 @@ mod tests { let sonarr_data = SonarrData::default(); assert!(!sonarr_data.add_list_exclusion); + assert!(sonarr_data.add_searched_series.is_none()); + assert!(sonarr_data.add_series_search.is_none()); + assert!(sonarr_data.add_series_modal.is_none()); assert!(sonarr_data.blocklist.is_empty()); assert!(!sonarr_data.delete_series_files); assert!(sonarr_data.downloads.is_empty()); diff --git a/src/models/sonarr_models.rs b/src/models/sonarr_models.rs index ae10239..46a4d04 100644 --- a/src/models/sonarr_models.rs +++ b/src/models/sonarr_models.rs @@ -27,19 +27,46 @@ mod sonarr_models_tests; pub struct AddSeriesBody { pub tvdb_id: i64, pub title: String, + pub monitored: bool, pub root_folder_path: String, pub quality_profile_id: i64, - pub series_type: SeriesType, - pub season_folder: bool, pub language_profile_id: i64, + pub series_type: String, + pub season_folder: bool, pub tags: Vec, pub add_options: AddSeriesOptions, } +#[derive(Derivative, Serialize, Deserialize, Debug, Default, Clone, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct AddSeriesSearchResult { + #[serde(deserialize_with = "super::from_i64")] + pub tvdb_id: i64, + pub title: HorizontallyScrollableText, + pub status: Option, + pub ended: bool, + pub overview: Option, + pub genres: Vec, + #[serde(deserialize_with = "super::from_i64")] + pub year: i64, + pub network: Option, + #[serde(deserialize_with = "super::from_i64")] + pub runtime: i64, + pub ratings: Option, + pub statistics: Option, +} + +#[derive(Derivative, Serialize, Deserialize, Debug, Default, Clone, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct AddSeriesSearchResultStatistics { + #[serde(deserialize_with = "super::from_i64")] + pub season_count: i64, +} + #[derive(Default, Clone, Serialize, Debug, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct AddSeriesOptions { - pub monitor: SeriesMonitor, + pub monitor: String, pub search_for_cutoff_unmet_episodes: bool, pub search_for_missing_episodes: bool, } @@ -258,9 +285,9 @@ pub struct Series { )] #[serde(rename_all = "camelCase")] pub enum SeriesMonitor { - Unknown, #[default] All, + Unknown, Future, Missing, Existing, diff --git a/src/network/radarr_network.rs b/src/network/radarr_network.rs index 82b2b1b..cbf8ae6 100644 --- a/src/network/radarr_network.rs +++ b/src/network/radarr_network.rs @@ -299,7 +299,7 @@ impl<'a, 'b> Network<'a, 'b> { .tags .text .clone(); - let tag_ids_vec = self.extract_and_add_tag_ids_vec(tags).await; + let tag_ids_vec = self.extract_and_add_radarr_tag_ids_vec(tags).await; let mut app = self.app.lock().await; let AddMovieModal { root_folder_list, @@ -1037,7 +1037,7 @@ impl<'a, 'b> Network<'a, 'b> { .tags .text .clone(); - let tag_ids_vec = self.extract_and_add_tag_ids_vec(tags).await; + let tag_ids_vec = self.extract_and_add_radarr_tag_ids_vec(tags).await; let mut app = self.app.lock().await; let params = { @@ -1222,7 +1222,7 @@ impl<'a, 'b> Network<'a, 'b> { .tags .text .clone(); - let tag_ids_vec = self.extract_and_add_tag_ids_vec(tags).await; + let tag_ids_vec = self.extract_and_add_radarr_tag_ids_vec(tags).await; let mut app = self.app.lock().await; let params = { @@ -2237,7 +2237,7 @@ impl<'a, 'b> Network<'a, 'b> { .await } - async fn extract_and_add_tag_ids_vec(&mut self, edit_tags: String) -> Vec { + async fn extract_and_add_radarr_tag_ids_vec(&mut self, edit_tags: String) -> Vec { let tags_map = self.app.lock().await.data.radarr_data.tags_map.clone(); let tags = edit_tags.clone(); let missing_tags_vec = edit_tags diff --git a/src/network/radarr_network_tests.rs b/src/network/radarr_network_tests.rs index 7d8b442..be349a0 100644 --- a/src/network/radarr_network_tests.rs +++ b/src/network/radarr_network_tests.rs @@ -4876,7 +4876,7 @@ mod test { } #[tokio::test] - async fn test_extract_and_add_tag_ids_vec() { + async fn test_extract_and_add_radarr_tag_ids_vec() { let app_arc = Arc::new(Mutex::new(App::default())); let tags = " test,hi ,, usenet ".to_owned(); { @@ -4890,13 +4890,13 @@ mod test { let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); assert_eq!( - network.extract_and_add_tag_ids_vec(tags).await, + network.extract_and_add_radarr_tag_ids_vec(tags).await, vec![2, 3, 1] ); } #[tokio::test] - async fn test_extract_and_add_tag_ids_vec_add_missing_tags_first() { + async fn test_extract_and_add_radarr_tag_ids_vec_add_missing_tags_first() { let (async_server, app_arc, _server) = mock_servarr_api( RequestMethod::Post, Some(json!({ "label": "testing" })), @@ -4919,7 +4919,7 @@ mod test { } let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); - let tag_ids_vec = network.extract_and_add_tag_ids_vec(tags).await; + let tag_ids_vec = network.extract_and_add_radarr_tag_ids_vec(tags).await; async_server.assert_async().await; assert_eq!(tag_ids_vec, vec![1, 2, 3]); diff --git a/src/network/sonarr_network.rs b/src/network/sonarr_network.rs index fac78d7..14001ad 100644 --- a/src/network/sonarr_network.rs +++ b/src/network/sonarr_network.rs @@ -9,7 +9,7 @@ use crate::{ servarr_data::{ modals::IndexerTestResultModalItem, sonarr::{ - modals::{EpisodeDetailsModal, SeasonDetailsModal}, + modals::{AddSeriesModal, EpisodeDetailsModal, SeasonDetailsModal}, sonarr_data::ActiveSonarrBlock, }, }, @@ -18,10 +18,10 @@ use crate::{ QualityProfile, QueueEvent, RootFolder, SecurityConfig, Tag, Update, }, sonarr_models::{ - BlocklistResponse, DeleteSeriesParams, DownloadRecord, DownloadsResponse, Episode, - IndexerSettings, Series, SonarrCommandBody, SonarrHistoryItem, SonarrHistoryWrapper, - SonarrRelease, SonarrReleaseDownloadBody, SonarrSerdeable, SonarrTask, SonarrTaskName, - SystemStatus, + AddSeriesBody, AddSeriesOptions, AddSeriesSearchResult, BlocklistResponse, + DeleteSeriesParams, DownloadRecord, DownloadsResponse, Episode, IndexerSettings, Series, + SonarrCommandBody, SonarrHistoryItem, SonarrHistoryWrapper, SonarrRelease, + SonarrReleaseDownloadBody, SonarrSerdeable, SonarrTask, SonarrTaskName, SystemStatus, }, stateful_table::StatefulTable, HorizontallyScrollableText, Route, Scrollable, ScrollableText, @@ -38,6 +38,7 @@ mod sonarr_network_tests; #[derive(Debug, Eq, PartialEq, Clone)] pub enum SonarrEvent { AddRootFolder(Option), + AddSeries(Option), AddTag(String), ClearBlocklist, DeleteBlocklistItem(Option), @@ -120,9 +121,10 @@ impl NetworkResource for SonarrEvent { SonarrEvent::GetTasks => "/system/task", SonarrEvent::GetUpdates => "/update", SonarrEvent::HealthCheck => "/health", - SonarrEvent::ListSeries | SonarrEvent::GetSeriesDetails(_) | SonarrEvent::DeleteSeries(_) => { - "/series" - } + SonarrEvent::AddSeries(_) + | SonarrEvent::ListSeries + | SonarrEvent::GetSeriesDetails(_) + | SonarrEvent::DeleteSeries(_) => "/series", SonarrEvent::MarkHistoryItemAsFailed(_) => "/history/failed", SonarrEvent::TestIndexer(_) => "/indexer/test", SonarrEvent::TestAllIndexers => "/indexer/testall", @@ -146,6 +148,10 @@ impl<'a, 'b> Network<'a, 'b> { .add_sonarr_root_folder(path) .await .map(SonarrSerdeable::from), + SonarrEvent::AddSeries(body) => self + .add_sonarr_series(body) + .await + .map(SonarrSerdeable::from), SonarrEvent::AddTag(tag) => self.add_sonarr_tag(tag).await.map(SonarrSerdeable::from), SonarrEvent::ClearBlocklist => self .clear_sonarr_blocklist() @@ -327,6 +333,106 @@ impl<'a, 'b> Network<'a, 'b> { .await } + async fn add_sonarr_series( + &mut self, + add_series_body_option: Option, + ) -> Result { + info!("Adding new series to Sonarr"); + let event = SonarrEvent::AddSeries(None); + let body = if let Some(add_series_body) = add_series_body_option { + add_series_body + } else { + let tags = self + .app + .lock() + .await + .data + .sonarr_data + .add_series_modal + .as_ref() + .unwrap() + .tags + .text + .clone(); + let tag_ids_vec = self.extract_and_add_sonarr_tag_ids_vec(tags).await; + let mut app = self.app.lock().await; + let AddSeriesModal { + root_folder_list, + monitor_list, + quality_profile_list, + language_profile_list, + series_type_list, + use_season_folder, + .. + } = app.data.sonarr_data.add_series_modal.as_ref().unwrap(); + let season_folder = *use_season_folder; + let (tvdb_id, title) = { + let AddSeriesSearchResult { tvdb_id, title, .. } = app + .data + .sonarr_data + .add_searched_series + .as_ref() + .unwrap() + .current_selection() + .clone(); + (tvdb_id, title.text) + }; + let quality_profile = quality_profile_list.current_selection(); + let quality_profile_id = *app + .data + .sonarr_data + .quality_profile_map + .iter() + .filter(|(_, value)| *value == quality_profile) + .map(|(key, _)| key) + .next() + .unwrap(); + let language_profile = language_profile_list.current_selection(); + let language_profile_id = *app + .data + .sonarr_data + .language_profiles_map + .iter() + .filter(|(_, value)| *value == language_profile) + .map(|(key, _)| key) + .next() + .unwrap(); + + let path = root_folder_list.current_selection().path.clone(); + let monitor = monitor_list.current_selection().to_string(); + let series_type = series_type_list.current_selection().to_string(); + + app.data.sonarr_data.add_series_modal = None; + + AddSeriesBody { + tvdb_id, + title, + monitored: true, + root_folder_path: path, + quality_profile_id, + language_profile_id, + series_type, + season_folder, + tags: tag_ids_vec, + add_options: AddSeriesOptions { + monitor, + search_for_cutoff_unmet_episodes: true, + search_for_missing_episodes: true, + }, + } + }; + + debug!("Add series body: {body:?}"); + + let request_props = self + .request_props_from(event, RequestMethod::Post, Some(body), None, None) + .await; + + self + .handle_request::(request_props, |_, _| ()) + .await + } + async fn add_sonarr_tag(&mut self, tag: String) -> Result { info!("Adding a new Sonarr tag"); let event = SonarrEvent::AddTag(String::new()); @@ -1703,6 +1809,36 @@ impl<'a, 'b> Network<'a, 'b> { .await } + async fn extract_and_add_sonarr_tag_ids_vec(&mut self, edit_tags: String) -> Vec { + let tags_map = self.app.lock().await.data.sonarr_data.tags_map.clone(); + let tags = edit_tags.clone(); + let missing_tags_vec = edit_tags + .split(',') + .filter(|&tag| !tag.is_empty() && tags_map.get_by_right(tag.trim()).is_none()) + .collect::>(); + + for tag in missing_tags_vec { + self + .add_sonarr_tag(tag.trim().to_owned()) + .await + .expect("Unable to add tag"); + } + + let app = self.app.lock().await; + tags + .split(',') + .filter(|tag| !tag.is_empty()) + .map(|tag| { + *app + .data + .sonarr_data + .tags_map + .get_by_right(tag.trim()) + .unwrap() + }) + .collect() + } + async fn extract_series_id(&mut self, series_id: Option) -> (i64, String) { let series_id = if let Some(id) = series_id { id diff --git a/src/network/sonarr_network_tests.rs b/src/network/sonarr_network_tests.rs index f7a0b63..82349ee 100644 --- a/src/network/sonarr_network_tests.rs +++ b/src/network/sonarr_network_tests.rs @@ -11,13 +11,21 @@ mod test { use rstest::rstest; use serde_json::json; use serde_json::{Number, Value}; + use strum::IntoEnumIterator; use tokio::sync::Mutex; use tokio_util::sync::CancellationToken; + use crate::models::sonarr_models::{ + AddSeriesBody, AddSeriesOptions, AddSeriesSearchResult, AddSeriesSearchResultStatistics, + SeriesMonitor, + }; + use crate::app::App; use crate::models::radarr_models::IndexerTestResult; use crate::models::servarr_data::modals::IndexerTestResultModalItem; - use crate::models::servarr_data::sonarr::modals::{EpisodeDetailsModal, SeasonDetailsModal}; + use crate::models::servarr_data::sonarr::modals::{ + AddSeriesModal, EpisodeDetailsModal, SeasonDetailsModal, + }; use crate::models::servarr_data::sonarr::sonarr_data::ActiveSonarrBlock; use crate::models::servarr_models::{ DiskSpace, HostConfig, Indexer, IndexerField, Language, LogResponse, Quality, QualityProfile, @@ -33,7 +41,7 @@ mod test { use crate::models::sonarr_models::{SonarrTask, SystemStatus}; use crate::models::stateful_table::StatefulTable; use crate::models::{sonarr_models::SonarrSerdeable, stateful_table::SortOption}; - use crate::models::{HorizontallyScrollableText, ScrollableText}; + use crate::models::{HorizontallyScrollableText, Scrollable, ScrollableText}; use crate::network::sonarr_network::get_episode_status; use crate::{ @@ -138,6 +146,7 @@ mod test { #[rstest] fn test_resource_series( #[values( + SonarrEvent::AddSeries(None), SonarrEvent::ListSeries, SonarrEvent::GetSeriesDetails(None), SonarrEvent::DeleteSeries(None) @@ -321,6 +330,267 @@ mod test { .is_none()); } + #[tokio::test] + async fn test_handle_add_sonarr_series_event() { + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Post, + Some(json!({ + "tvdbId": 1234, + "title": "Test", + "monitored": true, + "rootFolderPath": "/nfs2", + "qualityProfileId": 2222, + "languageProfileId": 2222, + "seriesType": "standard", + "seasonFolder": true, + "tags": [1, 2], + "addOptions": { + "monitor": "all", + "searchForCutoffUnmetEpisodes": true, + "searchForMissingEpisodes": true + } + })), + Some(json!({})), + None, + SonarrEvent::AddSeries(None), + None, + None, + ) + .await; + + { + let mut app = app_arc.lock().await; + let mut add_series_modal = AddSeriesModal { + use_season_folder: true, + tags: "usenet, testing".into(), + ..AddSeriesModal::default() + }; + add_series_modal.root_folder_list.set_items(vec![ + RootFolder { + id: 1, + path: "/nfs".to_owned(), + accessible: true, + free_space: 219902325555200, + unmapped_folders: None, + }, + RootFolder { + id: 2, + path: "/nfs2".to_owned(), + accessible: true, + free_space: 21990232555520, + unmapped_folders: None, + }, + ]); + add_series_modal.root_folder_list.state.select(Some(1)); + add_series_modal + .quality_profile_list + .set_items(vec!["HD - 1080p".to_owned()]); + add_series_modal + .language_profile_list + .set_items(vec!["English".to_owned()]); + add_series_modal + .monitor_list + .set_items(Vec::from_iter(SeriesMonitor::iter())); + add_series_modal + .series_type_list + .set_items(Vec::from_iter(SeriesType::iter())); + app.data.sonarr_data.add_series_modal = Some(add_series_modal); + app.data.sonarr_data.quality_profile_map = + BiMap::from_iter([(2222, "HD - 1080p".to_owned())]); + app.data.sonarr_data.language_profiles_map = BiMap::from_iter([(2222, "English".to_owned())]); + app.data.sonarr_data.tags_map = + BiMap::from_iter([(1, "usenet".to_owned()), (2, "testing".to_owned())]); + let mut add_searched_series = StatefulTable::default(); + add_searched_series.set_items(vec![add_series_search_result()]); + app.data.sonarr_data.add_searched_series = Some(add_searched_series); + } + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + assert!(network + .handle_sonarr_event(SonarrEvent::AddSeries(None)) + .await + .is_ok()); + + async_server.assert_async().await; + assert!(app_arc + .lock() + .await + .data + .sonarr_data + .add_series_modal + .is_none()); + } + + #[tokio::test] + async fn test_handle_add_sonarr_series_event_uses_provided_body() { + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Post, + Some(json!({ + "tvdbId": 1234, + "title": "Test", + "monitored": true, + "rootFolderPath": "/nfs2", + "qualityProfileId": 2222, + "languageProfileId": 2222, + "seriesType": "standard", + "seasonFolder": true, + "tags": [1, 2], + "addOptions": { + "monitor": "standard", + "searchForCutoffUnmetEpisodes": true, + "searchForMissingEpisodes": true + } + })), + Some(json!({})), + None, + SonarrEvent::AddSeries(None), + None, + None, + ) + .await; + let body = AddSeriesBody { + tvdb_id: 1234, + title: "Test".to_owned(), + monitored: true, + root_folder_path: "/nfs2".to_owned(), + quality_profile_id: 2222, + language_profile_id: 2222, + series_type: "standard".to_owned(), + season_folder: true, + tags: vec![1, 2], + add_options: AddSeriesOptions { + monitor: "standard".to_owned(), + search_for_cutoff_unmet_episodes: true, + search_for_missing_episodes: true, + }, + }; + + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + assert!(network + .handle_sonarr_event(SonarrEvent::AddSeries(Some(body))) + .await + .is_ok()); + + async_server.assert_async().await; + assert!(app_arc + .lock() + .await + .data + .sonarr_data + .add_series_modal + .is_none()); + } + + #[tokio::test] + async fn test_handle_add_sonarr_series_event_reuse_existing_table_if_search_already_performed() { + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Post, + Some(json!({ + "tvdbId": 5678, + "title": "Test", + "monitored": true, + "rootFolderPath": "/nfs2", + "qualityProfileId": 2222, + "languageProfileId": 2222, + "seriesType": "standard", + "seasonFolder": true, + "tags": [1, 2], + "addOptions": { + "monitor": "all", + "searchForCutoffUnmetEpisodes": true, + "searchForMissingEpisodes": true + } + })), + Some(json!({})), + None, + SonarrEvent::AddSeries(None), + None, + None, + ) + .await; + + { + let mut app = app_arc.lock().await; + let mut add_series_modal = AddSeriesModal { + use_season_folder: true, + tags: "usenet, testing".into(), + ..AddSeriesModal::default() + }; + add_series_modal.root_folder_list.set_items(vec![ + RootFolder { + id: 1, + path: "/nfs".to_owned(), + accessible: true, + free_space: 219902325555200, + unmapped_folders: None, + }, + RootFolder { + id: 2, + path: "/nfs2".to_owned(), + accessible: true, + free_space: 21990232555520, + unmapped_folders: None, + }, + ]); + add_series_modal.root_folder_list.state.select(Some(1)); + add_series_modal + .quality_profile_list + .set_items(vec!["HD - 1080p".to_owned()]); + add_series_modal + .language_profile_list + .set_items(vec!["English".to_owned()]); + add_series_modal + .monitor_list + .set_items(Vec::from_iter(SeriesMonitor::iter())); + add_series_modal + .series_type_list + .set_items(Vec::from_iter(SeriesType::iter())); + app.data.sonarr_data.add_series_modal = Some(add_series_modal); + app.data.sonarr_data.quality_profile_map = + BiMap::from_iter([(2222, "HD - 1080p".to_owned())]); + app.data.sonarr_data.language_profiles_map = BiMap::from_iter([(2222, "English".to_owned())]); + app.data.sonarr_data.tags_map = + BiMap::from_iter([(1, "usenet".to_owned()), (2, "testing".to_owned())]); + let secondary_search_result = AddSeriesSearchResult { + tvdb_id: 5678, + ..add_series_search_result() + }; + let mut add_searched_series = StatefulTable::default(); + add_searched_series.set_items(vec![add_series_search_result(), secondary_search_result]); + add_searched_series.scroll_to_bottom(); + app.data.sonarr_data.add_searched_series = Some(add_searched_series); + } + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + assert!(network + .handle_sonarr_event(SonarrEvent::AddSeries(None)) + .await + .is_ok()); + + async_server.assert_async().await; + assert!(app_arc + .lock() + .await + .data + .sonarr_data + .add_series_modal + .is_none()); + assert_eq!( + app_arc + .lock() + .await + .data + .sonarr_data + .add_searched_series + .as_ref() + .unwrap() + .current_selection() + .tvdb_id, + 5678 + ); + } + #[tokio::test] async fn test_handle_add_sonarr_tag() { let tag_json = json!({ "id": 3, "label": "testing" }); @@ -4851,6 +5121,64 @@ mod test { async_server.assert_async().await; } + #[tokio::test] + async fn test_extract_and_add_sonarr_tag_ids_vec() { + let app_arc = Arc::new(Mutex::new(App::default())); + let tags = " test,hi ,, usenet ".to_owned(); + { + let mut app = app_arc.lock().await; + app.data.sonarr_data.tags_map = BiMap::from_iter([ + (1, "usenet".to_owned()), + (2, "test".to_owned()), + (3, "hi".to_owned()), + ]); + } + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + assert_eq!( + network.extract_and_add_sonarr_tag_ids_vec(tags).await, + vec![2, 3, 1] + ); + } + + #[tokio::test] + async fn test_extract_and_add_sonarr_tag_ids_vec_add_missing_tags_first() { + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Post, + Some(json!({ "label": "testing" })), + Some(json!({ "id": 3, "label": "testing" })), + None, + SonarrEvent::GetTags, + None, + None, + ) + .await; + let tags = "usenet, test, testing".to_owned(); + { + let mut app = app_arc.lock().await; + app.data.sonarr_data.add_series_modal = Some(AddSeriesModal { + tags: tags.clone().into(), + ..AddSeriesModal::default() + }); + app.data.sonarr_data.tags_map = + BiMap::from_iter([(1, "usenet".to_owned()), (2, "test".to_owned())]); + } + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + let tag_ids_vec = network.extract_and_add_sonarr_tag_ids_vec(tags).await; + + async_server.assert_async().await; + assert_eq!(tag_ids_vec, vec![1, 2, 3]); + assert_eq!( + app_arc.lock().await.data.sonarr_data.tags_map, + BiMap::from_iter([ + (1, "usenet".to_owned()), + (2, "test".to_owned()), + (3, "testing".to_owned()) + ]) + ); + } + #[tokio::test] async fn test_extract_series_id() { let app_arc = Arc::new(Mutex::new(App::default())); @@ -5084,6 +5412,26 @@ mod test { ); } + fn add_series_search_result() -> AddSeriesSearchResult { + AddSeriesSearchResult { + tvdb_id: 1234, + title: HorizontallyScrollableText::from("Test"), + status: Some("continuing".to_owned()), + ended: false, + overview: Some("New series blah blah blah".to_owned()), + genres: genres(), + year: 2023, + network: Some("Prime Video".to_owned()), + runtime: 60, + ratings: Some(rating()), + statistics: Some(add_series_search_result_statistics()), + } + } + + fn add_series_search_result_statistics() -> AddSeriesSearchResultStatistics { + AddSeriesSearchResultStatistics { season_count: 3 } + } + fn blocklist_item() -> BlocklistItem { BlocklistItem { id: 1, @@ -5151,6 +5499,10 @@ mod test { } } + fn genres() -> Vec { + vec!["cool".to_owned(), "family".to_owned(), "fun".to_owned()] + } + fn history_data() -> SonarrHistoryData { SonarrHistoryData { dropped_path: Some("/nfs/nzbget/completed/series/Coolness/something.cool.mkv".to_owned()), From 8125bd5ae0d8920fbfccb8bc61852a276efcc73d Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Sun, 24 Nov 2024 14:29:13 -0700 Subject: [PATCH 099/119] feat(cli): Support for adding a series to Sonarr --- src/cli/radarr/add_command_handler.rs | 10 +- src/cli/radarr/add_command_handler_tests.rs | 16 +- src/cli/sonarr/add_command_handler.rs | 94 ++++- src/cli/sonarr/add_command_handler_tests.rs | 373 +++++++++++++++++- .../library/add_movie_handler_tests.rs | 6 +- src/models/radarr_models.rs | 22 +- src/models/radarr_models_tests.rs | 14 +- src/models/servarr_data/radarr/modals.rs | 6 +- .../servarr_data/radarr/modals_tests.rs | 4 +- src/models/servarr_data/sonarr/sonarr_data.rs | 7 + src/network/radarr_network.rs | 12 +- src/network/radarr_network_tests.rs | 10 +- 12 files changed, 523 insertions(+), 51 deletions(-) diff --git a/src/cli/radarr/add_command_handler.rs b/src/cli/radarr/add_command_handler.rs index 70efb70..0c35a7e 100644 --- a/src/cli/radarr/add_command_handler.rs +++ b/src/cli/radarr/add_command_handler.rs @@ -7,7 +7,7 @@ use tokio::sync::Mutex; use crate::{ app::App, cli::{CliCommandHandler, Command}, - models::radarr_models::{AddMovieBody, AddOptions, MinimumAvailability, Monitor}, + models::radarr_models::{AddMovieBody, AddMovieOptions, MinimumAvailability, MovieMonitor}, network::{radarr_network::RadarrEvent, NetworkTrait}, }; @@ -46,7 +46,7 @@ pub enum RadarrAddCommand { default_value_t = MinimumAvailability::default() )] minimum_availability: MinimumAvailability, - #[arg(long, help = "Should Radarr monitor this film")] + #[arg(long, help = "Disable monitoring for this film")] disable_monitoring: bool, #[arg( long, @@ -59,9 +59,9 @@ pub enum RadarrAddCommand { long, help = "What Radarr should monitor", value_enum, - default_value_t = Monitor::default() + default_value_t = MovieMonitor::default() )] - monitor: Monitor, + monitor: MovieMonitor, #[arg( long, help = "Tell Radarr to not start a search for this film once it's added to your library" @@ -125,7 +125,7 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, RadarrAddCommand> for RadarrAddCommandHan minimum_availability: minimum_availability.to_string(), monitored: !disable_monitoring, tags, - add_options: AddOptions { + add_options: AddMovieOptions { monitor: monitor.to_string(), search_for_movie: !no_search_for_movie, }, diff --git a/src/cli/radarr/add_command_handler_tests.rs b/src/cli/radarr/add_command_handler_tests.rs index 59c9e29..754abd2 100644 --- a/src/cli/radarr/add_command_handler_tests.rs +++ b/src/cli/radarr/add_command_handler_tests.rs @@ -7,7 +7,7 @@ mod tests { radarr::{add_command_handler::RadarrAddCommand, RadarrCommand}, Command, }, - models::radarr_models::{MinimumAvailability, Monitor}, + models::radarr_models::{MinimumAvailability, MovieMonitor}, Cli, }; use pretty_assertions::assert_eq; @@ -112,6 +112,8 @@ mod tests { "/test", "--quality-profile-id", "1", + "--tmdb-id", + "1", flag, ]); @@ -188,7 +190,7 @@ mod tests { minimum_availability: MinimumAvailability::default(), disable_monitoring: false, tag: vec![], - monitor: Monitor::default(), + monitor: MovieMonitor::default(), no_search_for_movie: false, }; @@ -220,7 +222,7 @@ mod tests { minimum_availability: MinimumAvailability::default(), disable_monitoring: false, tag: vec![1, 2], - monitor: Monitor::default(), + monitor: MovieMonitor::default(), no_search_for_movie: false, }; @@ -256,7 +258,7 @@ mod tests { minimum_availability: MinimumAvailability::Released, disable_monitoring: true, tag: vec![1, 2], - monitor: Monitor::MovieAndCollection, + monitor: MovieMonitor::MovieAndCollection, no_search_for_movie: true, }; @@ -357,7 +359,7 @@ mod tests { app::App, cli::{radarr::add_command_handler::RadarrAddCommandHandler, CliCommandHandler}, models::{ - radarr_models::{AddMovieBody, AddOptions, RadarrSerdeable}, + radarr_models::{AddMovieBody, AddMovieOptions, RadarrSerdeable}, Serdeable, }, network::{radarr_network::RadarrEvent, MockNetworkTrait, NetworkEvent}, @@ -379,7 +381,7 @@ mod tests { minimum_availability: "released".to_owned(), monitored: false, tags: vec![1, 2], - add_options: AddOptions { + add_options: AddMovieOptions { monitor: "movieAndCollection".to_owned(), search_for_movie: false, }, @@ -404,7 +406,7 @@ mod tests { minimum_availability: MinimumAvailability::Released, disable_monitoring: true, tag: vec![1, 2], - monitor: Monitor::MovieAndCollection, + monitor: MovieMonitor::MovieAndCollection, no_search_for_movie: true, }; diff --git a/src/cli/sonarr/add_command_handler.rs b/src/cli/sonarr/add_command_handler.rs index 3cb3978..b1ae7b5 100644 --- a/src/cli/sonarr/add_command_handler.rs +++ b/src/cli/sonarr/add_command_handler.rs @@ -1,12 +1,13 @@ use std::sync::Arc; use anyhow::Result; -use clap::Subcommand; +use clap::{ArgAction, Subcommand}; use tokio::sync::Mutex; use crate::{ app::App, cli::{CliCommandHandler, Command}, + models::sonarr_models::{AddSeriesBody, AddSeriesOptions, SeriesMonitor, SeriesType}, network::{sonarr_network::SonarrEvent, NetworkTrait}, }; @@ -18,6 +19,63 @@ mod add_command_handler_tests; #[derive(Debug, Clone, PartialEq, Eq, Subcommand)] pub enum SonarrAddCommand { + #[command(about = "Add a new series to your Sonarr library")] + Series { + #[arg( + long, + help = "The TVDB ID of the series you wish to add to your library", + required = true + )] + tvdb_id: i64, + #[arg( + long, + help = "The root folder path where all series data and metadata should live", + required = true + )] + root_folder_path: String, + #[arg( + long, + help = "The ID of the quality profile to use for this series", + required = true + )] + quality_profile_id: i64, + #[arg( + long, + help = "The ID of the language profile to use for this series", + required = true + )] + language_profile_id: i64, + #[arg( + long, + help = "The type of series", + value_enum, + default_value_t = SeriesType::default() + )] + series_type: SeriesType, + #[arg(long, help = "Disable monitoring for this series")] + disable_monitoring: bool, + #[arg(long, help = "Don't use season folders for this series")] + disable_season_folders: bool, + #[arg( + long, + help = "Tag IDs to tag the series with", + value_parser, + action = ArgAction::Append + )] + tag: Vec, + #[arg( + long, + help = "What Sonarr should monitor", + value_enum, + default_value_t = SeriesMonitor::default() + )] + monitor: SeriesMonitor, + #[arg( + long, + help = "Tell Sonarr to not start a search for this series once it's added to your library" + )] + no_search_for_series: bool, + }, #[command(about = "Add a new root folder")] RootFolder { #[arg(long, help = "The path of the new root folder", required = true)] @@ -57,6 +115,40 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, SonarrAddCommand> for SonarrAddCommandHan async fn handle(self) -> Result { let result = match self.command { + SonarrAddCommand::Series { + tvdb_id, + root_folder_path, + quality_profile_id, + language_profile_id, + series_type, + disable_monitoring, + disable_season_folders, + tag: tags, + monitor, + no_search_for_series, + } => { + let body = AddSeriesBody { + tvdb_id, + title: String::new(), + monitored: !disable_monitoring, + root_folder_path, + quality_profile_id, + language_profile_id, + series_type: series_type.to_string(), + season_folder: !disable_season_folders, + tags, + add_options: AddSeriesOptions { + monitor: monitor.to_string(), + search_for_cutoff_unmet_episodes: !no_search_for_series, + search_for_missing_episodes: !no_search_for_series, + }, + }; + let resp = self + .network + .handle_network_event(SonarrEvent::AddSeries(Some(body)).into()) + .await?; + serde_json::to_string_pretty(&resp)? + } SonarrAddCommand::RootFolder { root_folder_path } => { let resp = self .network diff --git a/src/cli/sonarr/add_command_handler_tests.rs b/src/cli/sonarr/add_command_handler_tests.rs index 9a6f251..03b4016 100644 --- a/src/cli/sonarr/add_command_handler_tests.rs +++ b/src/cli/sonarr/add_command_handler_tests.rs @@ -23,8 +23,11 @@ mod tests { } mod cli { + use crate::models::sonarr_models::{SeriesMonitor, SeriesType}; + use super::*; use pretty_assertions::assert_eq; + use rstest::rstest; #[test] fn test_add_root_folder_requires_arguments() { @@ -60,6 +63,318 @@ mod tests { } } + #[test] + fn test_add_series_requires_arguments() { + let result = Cli::command().try_get_matches_from(["managarr", "sonarr", "add", "series"]); + + assert!(result.is_err()); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_add_series_requires_tvdb_id() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "sonarr", + "add", + "series", + "--root-folder-path", + "test", + "--quality-profile-id", + "1", + "--language-profile-id", + "1", + ]); + + assert!(result.is_err()); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_add_series_requires_root_folder_path() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "sonarr", + "add", + "series", + "--tvdb-id", + "1", + "--quality-profile-id", + "1", + "--language-profile-id", + "1", + ]); + + assert!(result.is_err()); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_add_series_requires_quality_profile_id() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "sonarr", + "add", + "series", + "--tvdb-id", + "1", + "--root-folder-path", + "test", + "--language-profile-id", + "1", + ]); + + assert!(result.is_err()); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_add_series_requires_language_profile_id() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "sonarr", + "add", + "series", + "--tvdb-id", + "1", + "--root-folder-path", + "test", + "--quality-profile-id", + "1", + ]); + + assert!(result.is_err()); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[rstest] + fn test_add_series_assert_argument_flags_require_args( + #[values("--series-type", "--tag", "--monitor")] flag: &str, + ) { + let result = Cli::command().try_get_matches_from([ + "managarr", + "sonarr", + "add", + "series", + "--tvdb-id", + "1", + "--root-folder-path", + "/test", + "--quality-profile-id", + "1", + "--language-profile-id", + "1", + flag, + ]); + + assert!(result.is_err()); + assert_eq!(result.unwrap_err().kind(), ErrorKind::InvalidValue); + } + + #[test] + fn test_add_series_all_arguments_satisfied() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "sonarr", + "add", + "series", + "--root-folder-path", + "/test", + "--quality-profile-id", + "1", + "--language-profile-id", + "1", + "--tvdb-id", + "1", + ]); + + assert!(result.is_ok()); + } + + #[test] + fn test_add_series_series_type_validation() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "sonarr", + "add", + "series", + "--root-folder-path", + "/test", + "--quality-profile-id", + "1", + "--language-profile-id", + "1", + "--tvdb-id", + "1", + "--series-type", + "test", + ]); + + assert!(result.is_err()); + assert_eq!(result.unwrap_err().kind(), ErrorKind::InvalidValue); + } + + #[test] + fn test_add_series_monitor_validation() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "sonarr", + "add", + "series", + "--root-folder-path", + "/test", + "--quality-profile-id", + "1", + "--language-profile-id", + "1", + "--tvdb-id", + "1", + "--monitor", + "test", + ]); + + assert!(result.is_err()); + assert_eq!(result.unwrap_err().kind(), ErrorKind::InvalidValue); + } + + #[test] + fn test_add_series_defaults() { + let expected_args = SonarrAddCommand::Series { + tvdb_id: 1, + root_folder_path: "/test".to_owned(), + quality_profile_id: 1, + language_profile_id: 1, + series_type: SeriesType::default(), + disable_monitoring: false, + disable_season_folders: false, + tag: vec![], + monitor: SeriesMonitor::default(), + no_search_for_series: false, + }; + + let result = Cli::try_parse_from([ + "managarr", + "sonarr", + "add", + "series", + "--root-folder-path", + "/test", + "--quality-profile-id", + "1", + "--language-profile-id", + "1", + "--tvdb-id", + "1", + ]); + + assert!(result.is_ok()); + if let Some(Command::Sonarr(SonarrCommand::Add(add_command))) = result.unwrap().command { + assert_eq!(add_command, expected_args); + } + } + + #[test] + fn test_add_series_tags_is_repeatable() { + let expected_args = SonarrAddCommand::Series { + tvdb_id: 1, + root_folder_path: "/test".to_owned(), + quality_profile_id: 1, + language_profile_id: 1, + series_type: SeriesType::default(), + disable_monitoring: false, + disable_season_folders: false, + tag: vec![1, 2], + monitor: SeriesMonitor::default(), + no_search_for_series: false, + }; + + let result = Cli::try_parse_from([ + "managarr", + "sonarr", + "add", + "series", + "--root-folder-path", + "/test", + "--quality-profile-id", + "1", + "--language-profile-id", + "1", + "--tvdb-id", + "1", + "--tag", + "1", + "--tag", + "2", + ]); + + assert!(result.is_ok()); + if let Some(Command::Sonarr(SonarrCommand::Add(add_command))) = result.unwrap().command { + assert_eq!(add_command, expected_args); + } + } + + #[test] + fn test_add_series_all_args_defined() { + let expected_args = SonarrAddCommand::Series { + tvdb_id: 1, + root_folder_path: "/test".to_owned(), + quality_profile_id: 1, + language_profile_id: 1, + series_type: SeriesType::Anime, + disable_monitoring: true, + disable_season_folders: true, + tag: vec![1, 2], + monitor: SeriesMonitor::Future, + no_search_for_series: true, + }; + + let result = Cli::try_parse_from([ + "managarr", + "sonarr", + "add", + "series", + "--root-folder-path", + "/test", + "--quality-profile-id", + "1", + "--language-profile-id", + "1", + "--series-type", + "anime", + "--disable-monitoring", + "--disable-season-folders", + "--tvdb-id", + "1", + "--tag", + "1", + "--tag", + "2", + "--monitor", + "future", + "--no-search-for-series", + ]); + + assert!(result.is_ok()); + if let Some(Command::Sonarr(SonarrCommand::Add(add_command))) = result.unwrap().command { + assert_eq!(add_command, expected_args); + } + } + #[test] fn test_add_tag_requires_arguments() { let result = Cli::command().try_get_matches_from(["managarr", "sonarr", "add", "tag"]); @@ -93,7 +408,12 @@ mod tests { use crate::{ app::App, cli::{sonarr::add_command_handler::SonarrAddCommandHandler, CliCommandHandler}, - models::{sonarr_models::SonarrSerdeable, Serdeable}, + models::{ + sonarr_models::{ + AddSeriesBody, AddSeriesOptions, SeriesMonitor, SeriesType, SonarrSerdeable, + }, + Serdeable, + }, network::{sonarr_network::SonarrEvent, MockNetworkTrait, NetworkEvent}, }; @@ -131,6 +451,57 @@ mod tests { assert!(result.is_ok()); } + #[tokio::test] + async fn test_handle_add_series_command() { + let expected_add_series_body = AddSeriesBody { + tvdb_id: 1, + title: String::new(), + root_folder_path: "/test".to_owned(), + quality_profile_id: 1, + language_profile_id: 1, + series_type: "anime".to_owned(), + monitored: false, + tags: vec![1, 2], + season_folder: false, + add_options: AddSeriesOptions { + monitor: "future".to_owned(), + search_for_cutoff_unmet_episodes: false, + search_for_missing_episodes: false, + }, + }; + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::( + SonarrEvent::AddSeries(Some(expected_add_series_body)).into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Sonarr(SonarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::default())); + let add_series_command = SonarrAddCommand::Series { + tvdb_id: 1, + root_folder_path: "/test".to_owned(), + quality_profile_id: 1, + language_profile_id: 1, + series_type: SeriesType::Anime, + disable_monitoring: true, + disable_season_folders: true, + tag: vec![1, 2], + monitor: SeriesMonitor::Future, + no_search_for_series: true, + }; + + let result = SonarrAddCommandHandler::with(&app_arc, add_series_command, &mut mock_network) + .handle() + .await; + + assert!(result.is_ok()); + } + #[tokio::test] async fn test_handle_add_tag_command() { let expected_tag_name = "test".to_owned(); diff --git a/src/handlers/radarr_handlers/library/add_movie_handler_tests.rs b/src/handlers/radarr_handlers/library/add_movie_handler_tests.rs index c0797ec..2800832 100644 --- a/src/handlers/radarr_handlers/library/add_movie_handler_tests.rs +++ b/src/handlers/radarr_handlers/library/add_movie_handler_tests.rs @@ -8,7 +8,7 @@ mod tests { use crate::event::Key; use crate::handlers::radarr_handlers::library::add_movie_handler::AddMovieHandler; use crate::handlers::KeyEventHandler; - use crate::models::radarr_models::{AddMovieSearchResult, MinimumAvailability, Monitor}; + use crate::models::radarr_models::{AddMovieSearchResult, MinimumAvailability, MovieMonitor}; use crate::models::servarr_data::radarr::radarr_data::{ActiveRadarrBlock, ADD_MOVIE_BLOCKS}; use crate::models::servarr_models::RootFolder; use crate::models::HorizontallyScrollableText; @@ -141,7 +141,7 @@ mod tests { fn test_add_movie_select_monitor_scroll( #[values(DEFAULT_KEYBINDINGS.up.key, DEFAULT_KEYBINDINGS.down.key)] key: Key, ) { - let monitor_vec = Vec::from_iter(Monitor::iter()); + let monitor_vec = Vec::from_iter(MovieMonitor::iter()); let mut app = App::default(); app.data.radarr_data.add_movie_modal = Some(AddMovieModal::default()); app @@ -534,7 +534,7 @@ mod tests { #[test] fn test_add_movie_select_monitor_home_end() { - let monitor_vec = Vec::from_iter(Monitor::iter()); + let monitor_vec = Vec::from_iter(MovieMonitor::iter()); let mut app = App::default(); app.data.radarr_data.add_movie_modal = Some(AddMovieModal::default()); app diff --git a/src/models/radarr_models.rs b/src/models/radarr_models.rs index cf37f7c..6049e5f 100644 --- a/src/models/radarr_models.rs +++ b/src/models/radarr_models.rs @@ -29,7 +29,7 @@ pub struct AddMovieBody { pub minimum_availability: String, pub monitored: bool, pub tags: Vec, - pub add_options: AddOptions, + pub add_options: AddMovieOptions, } #[derive(Derivative, Serialize, Deserialize, Debug, Default, Clone, PartialEq, Eq)] @@ -51,7 +51,7 @@ pub struct AddMovieSearchResult { #[derive(Default, Clone, Serialize, Debug, PartialEq, Eq)] #[serde(rename_all = "camelCase")] -pub struct AddOptions { +pub struct AddMovieOptions { pub monitor: String, pub search_for_movie: bool, } @@ -305,30 +305,30 @@ impl<'a> EnumDisplayStyle<'a> for MinimumAvailability { } #[derive(Default, PartialEq, Eq, Clone, Copy, Debug, EnumIter, ValueEnum)] -pub enum Monitor { +pub enum MovieMonitor { #[default] MovieOnly, MovieAndCollection, None, } -impl Display for Monitor { +impl Display for MovieMonitor { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { let monitor = match self { - Monitor::MovieOnly => "movieOnly", - Monitor::MovieAndCollection => "movieAndCollection", - Monitor::None => "none", + MovieMonitor::MovieOnly => "movieOnly", + MovieMonitor::MovieAndCollection => "movieAndCollection", + MovieMonitor::None => "none", }; write!(f, "{monitor}") } } -impl<'a> EnumDisplayStyle<'a> for Monitor { +impl<'a> EnumDisplayStyle<'a> for MovieMonitor { fn to_display_str(self) -> &'a str { match self { - Monitor::MovieOnly => "Movie only", - Monitor::MovieAndCollection => "Movie and Collection", - Monitor::None => "None", + MovieMonitor::MovieOnly => "Movie only", + MovieMonitor::MovieAndCollection => "Movie and Collection", + MovieMonitor::None => "None", } } } diff --git a/src/models/radarr_models_tests.rs b/src/models/radarr_models_tests.rs index d49c3cd..b260344 100644 --- a/src/models/radarr_models_tests.rs +++ b/src/models/radarr_models_tests.rs @@ -7,7 +7,7 @@ mod tests { radarr_models::{ AddMovieSearchResult, BlocklistItem, BlocklistResponse, Collection, Credit, DiskSpace, DownloadRecord, DownloadsResponse, Indexer, IndexerSettings, IndexerTestResult, - MinimumAvailability, Monitor, Movie, MovieHistoryItem, QualityProfile, RadarrRelease, + MinimumAvailability, Movie, MovieHistoryItem, MovieMonitor, QualityProfile, RadarrRelease, RadarrSerdeable, RadarrTask, RadarrTaskName, SystemStatus, Tag, Update, }, servarr_models::{HostConfig, Log, LogResponse, QueueEvent, RootFolder, SecurityConfig}, @@ -43,22 +43,22 @@ mod tests { #[test] fn test_monitor_display() { - assert_str_eq!(Monitor::MovieOnly.to_string(), "movieOnly"); + assert_str_eq!(MovieMonitor::MovieOnly.to_string(), "movieOnly"); assert_str_eq!( - Monitor::MovieAndCollection.to_string(), + MovieMonitor::MovieAndCollection.to_string(), "movieAndCollection" ); - assert_str_eq!(Monitor::None.to_string(), "none"); + assert_str_eq!(MovieMonitor::None.to_string(), "none"); } #[test] fn test_monitor_to_display_str() { - assert_str_eq!(Monitor::MovieOnly.to_display_str(), "Movie only"); + assert_str_eq!(MovieMonitor::MovieOnly.to_display_str(), "Movie only"); assert_str_eq!( - Monitor::MovieAndCollection.to_display_str(), + MovieMonitor::MovieAndCollection.to_display_str(), "Movie and Collection" ); - assert_str_eq!(Monitor::None.to_display_str(), "None"); + assert_str_eq!(MovieMonitor::None.to_display_str(), "None"); } #[test] diff --git a/src/models/servarr_data/radarr/modals.rs b/src/models/servarr_data/radarr/modals.rs index 9a98c4e..843d5ea 100644 --- a/src/models/servarr_data/radarr/modals.rs +++ b/src/models/servarr_data/radarr/modals.rs @@ -1,7 +1,7 @@ use strum::IntoEnumIterator; use crate::models::radarr_models::{ - Collection, Credit, MinimumAvailability, Monitor, Movie, MovieHistoryItem, RadarrRelease, + Collection, Credit, MinimumAvailability, Movie, MovieHistoryItem, MovieMonitor, RadarrRelease, }; use crate::models::servarr_data::radarr::radarr_data::RadarrData; use crate::models::servarr_models::{Indexer, RootFolder}; @@ -195,7 +195,7 @@ impl From<&RadarrData<'_>> for EditMovieModal { #[derive(Default)] pub struct AddMovieModal { pub root_folder_list: StatefulList, - pub monitor_list: StatefulList, + pub monitor_list: StatefulList, pub minimum_availability_list: StatefulList, pub quality_profile_list: StatefulList, pub tags: HorizontallyScrollableText, @@ -206,7 +206,7 @@ impl From<&RadarrData<'_>> for AddMovieModal { let mut add_movie_modal = AddMovieModal::default(); add_movie_modal .monitor_list - .set_items(Vec::from_iter(Monitor::iter())); + .set_items(Vec::from_iter(MovieMonitor::iter())); add_movie_modal .minimum_availability_list .set_items(Vec::from_iter(MinimumAvailability::iter())); diff --git a/src/models/servarr_data/radarr/modals_tests.rs b/src/models/servarr_data/radarr/modals_tests.rs index 62faa71..96e36ad 100644 --- a/src/models/servarr_data/radarr/modals_tests.rs +++ b/src/models/servarr_data/radarr/modals_tests.rs @@ -1,6 +1,6 @@ #[cfg(test)] mod test { - use crate::models::radarr_models::{Collection, MinimumAvailability, Monitor, Movie}; + use crate::models::radarr_models::{Collection, MinimumAvailability, Movie, MovieMonitor}; use crate::models::servarr_data::radarr::modals::{ AddMovieModal, EditCollectionModal, EditIndexerModal, EditMovieModal, }; @@ -185,7 +185,7 @@ mod test { assert_eq!( add_movie_modal.monitor_list.items, - Vec::from_iter(Monitor::iter()) + Vec::from_iter(MovieMonitor::iter()) ); assert_eq!( add_movie_modal.minimum_availability_list.items, diff --git a/src/models/servarr_data/sonarr/sonarr_data.rs b/src/models/servarr_data/sonarr/sonarr_data.rs index 4af41d6..acf0552 100644 --- a/src/models/servarr_data/sonarr/sonarr_data.rs +++ b/src/models/servarr_data/sonarr/sonarr_data.rs @@ -102,6 +102,13 @@ pub enum ActiveSonarrBlock { AddSeriesPrompt, AddSeriesSearchInput, AddSeriesSearchResults, + AddSeriesSelectLanguageProfile, + AddSeriesSelectMonitor, + AddSeriesSelectQualityProfile, + AddSeriesSelectRootFolder, + AddSeriesSelectSeriesType, + AddSeriesTagsInput, + AddSeriesToggleUseSeasonFolder, AllIndexerSettingsPrompt, AutomaticallySearchEpisodePrompt, AutomaticallySearchSeasonPrompt, diff --git a/src/network/radarr_network.rs b/src/network/radarr_network.rs index cbf8ae6..3e3c124 100644 --- a/src/network/radarr_network.rs +++ b/src/network/radarr_network.rs @@ -7,11 +7,11 @@ use serde_json::{json, Value}; use urlencoding::encode; use crate::models::radarr_models::{ - AddMovieBody, AddMovieSearchResult, AddOptions, BlocklistResponse, Collection, CollectionMovie, - Credit, CreditType, DeleteMovieParams, DownloadRecord, DownloadsResponse, EditCollectionParams, - EditIndexerParams, EditMovieParams, IndexerSettings, IndexerTestResult, Movie, MovieCommandBody, - MovieHistoryItem, RadarrRelease, RadarrReleaseDownloadBody, RadarrSerdeable, RadarrTask, - RadarrTaskName, SystemStatus, + AddMovieBody, AddMovieOptions, AddMovieSearchResult, BlocklistResponse, Collection, + CollectionMovie, Credit, CreditType, DeleteMovieParams, DownloadRecord, DownloadsResponse, + EditCollectionParams, EditIndexerParams, EditMovieParams, IndexerSettings, IndexerTestResult, + Movie, MovieCommandBody, MovieHistoryItem, RadarrRelease, RadarrReleaseDownloadBody, + RadarrSerdeable, RadarrTask, RadarrTaskName, SystemStatus, }; use crate::models::servarr_data::modals::IndexerTestResultModalItem; use crate::models::servarr_data::radarr::modals::{ @@ -365,7 +365,7 @@ impl<'a, 'b> Network<'a, 'b> { monitored: true, quality_profile_id, tags: tag_ids_vec, - add_options: AddOptions { + add_options: AddMovieOptions { monitor, search_for_movie: true, }, diff --git a/src/network/radarr_network_tests.rs b/src/network/radarr_network_tests.rs index be349a0..5093477 100644 --- a/src/network/radarr_network_tests.rs +++ b/src/network/radarr_network_tests.rs @@ -15,8 +15,8 @@ mod test { use crate::app::ServarrConfig; use crate::models::radarr_models::{ - BlocklistItem, BlocklistItemMovie, CollectionMovie, MediaInfo, MinimumAvailability, Monitor, - MovieCollection, MovieFile, Rating, RatingsList, + BlocklistItem, BlocklistItemMovie, CollectionMovie, MediaInfo, MinimumAvailability, + MovieCollection, MovieFile, MovieMonitor, Rating, RatingsList, }; use crate::models::servarr_data::radarr::radarr_data::ActiveRadarrBlock; use crate::models::servarr_models::{ @@ -3450,7 +3450,7 @@ mod test { .set_items(vec!["HD - 1080p".to_owned()]); add_movie_modal .monitor_list - .set_items(Vec::from_iter(Monitor::iter())); + .set_items(Vec::from_iter(MovieMonitor::iter())); add_movie_modal .minimum_availability_list .set_items(Vec::from_iter(MinimumAvailability::iter())); @@ -3521,7 +3521,7 @@ mod test { monitored: true, quality_profile_id: 2222, tags: vec![1, 2], - add_options: AddOptions { + add_options: AddMovieOptions { monitor: "movieOnly".to_owned(), search_for_movie: true, }, @@ -3597,7 +3597,7 @@ mod test { .set_items(vec!["HD - 1080p".to_owned()]); add_movie_modal .monitor_list - .set_items(Vec::from_iter(Monitor::iter())); + .set_items(Vec::from_iter(MovieMonitor::iter())); add_movie_modal .minimum_availability_list .set_items(Vec::from_iter(MinimumAvailability::iter())); From 53a59cdb4cf93708905a1cdb8f63e994156daa5f Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Sun, 24 Nov 2024 14:38:23 -0700 Subject: [PATCH 100/119] feat(cli): Support for adding a series to Sonarr --- src/cli/sonarr/add_command_handler.rs | 5 +- src/cli/sonarr/add_command_handler_tests.rs | 52 ++++++++++++++++++++- 2 files changed, 55 insertions(+), 2 deletions(-) diff --git a/src/cli/sonarr/add_command_handler.rs b/src/cli/sonarr/add_command_handler.rs index b1ae7b5..c842d0d 100644 --- a/src/cli/sonarr/add_command_handler.rs +++ b/src/cli/sonarr/add_command_handler.rs @@ -27,6 +27,8 @@ pub enum SonarrAddCommand { required = true )] tvdb_id: i64, + #[arg(long, help = "The title of the series", required = true)] + title: String, #[arg( long, help = "The root folder path where all series data and metadata should live", @@ -117,6 +119,7 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, SonarrAddCommand> for SonarrAddCommandHan let result = match self.command { SonarrAddCommand::Series { tvdb_id, + title, root_folder_path, quality_profile_id, language_profile_id, @@ -129,7 +132,7 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, SonarrAddCommand> for SonarrAddCommandHan } => { let body = AddSeriesBody { tvdb_id, - title: String::new(), + title, monitored: !disable_monitoring, root_folder_path, quality_profile_id, diff --git a/src/cli/sonarr/add_command_handler_tests.rs b/src/cli/sonarr/add_command_handler_tests.rs index 03b4016..8018b9e 100644 --- a/src/cli/sonarr/add_command_handler_tests.rs +++ b/src/cli/sonarr/add_command_handler_tests.rs @@ -87,6 +87,32 @@ mod tests { "1", "--language-profile-id", "1", + "--title", + "test", + ]); + + assert!(result.is_err()); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_add_series_requires_title() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "sonarr", + "add", + "series", + "--tvdb-id", + "1", + "--root-folder-path", + "test", + "--quality-profile-id", + "1", + "--language-profile-id", + "1", ]); assert!(result.is_err()); @@ -109,6 +135,8 @@ mod tests { "1", "--language-profile-id", "1", + "--title", + "test", ]); assert!(result.is_err()); @@ -131,6 +159,8 @@ mod tests { "test", "--language-profile-id", "1", + "--title", + "test", ]); assert!(result.is_err()); @@ -153,6 +183,8 @@ mod tests { "test", "--quality-profile-id", "1", + "--title", + "test", ]); assert!(result.is_err()); @@ -173,6 +205,8 @@ mod tests { "series", "--tvdb-id", "1", + "--title", + "test", "--root-folder-path", "/test", "--quality-profile-id", @@ -193,6 +227,8 @@ mod tests { "sonarr", "add", "series", + "--title", + "test", "--root-folder-path", "/test", "--quality-profile-id", @@ -221,6 +257,8 @@ mod tests { "1", "--tvdb-id", "1", + "--title", + "test", "--series-type", "test", ]); @@ -243,6 +281,8 @@ mod tests { "--language-profile-id", "1", "--tvdb-id", + "--title", + "test", "1", "--monitor", "test", @@ -256,6 +296,7 @@ mod tests { fn test_add_series_defaults() { let expected_args = SonarrAddCommand::Series { tvdb_id: 1, + title: "test".to_owned(), root_folder_path: "/test".to_owned(), quality_profile_id: 1, language_profile_id: 1, @@ -278,6 +319,8 @@ mod tests { "1", "--language-profile-id", "1", + "--title", + "test", "--tvdb-id", "1", ]); @@ -292,6 +335,7 @@ mod tests { fn test_add_series_tags_is_repeatable() { let expected_args = SonarrAddCommand::Series { tvdb_id: 1, + title: "test".to_owned(), root_folder_path: "/test".to_owned(), quality_profile_id: 1, language_profile_id: 1, @@ -316,6 +360,8 @@ mod tests { "1", "--tvdb-id", "1", + "--title", + "test", "--tag", "1", "--tag", @@ -332,6 +378,7 @@ mod tests { fn test_add_series_all_args_defined() { let expected_args = SonarrAddCommand::Series { tvdb_id: 1, + title: "test".to_owned(), root_folder_path: "/test".to_owned(), quality_profile_id: 1, language_profile_id: 1, @@ -360,6 +407,8 @@ mod tests { "--disable-season-folders", "--tvdb-id", "1", + "--title", + "test", "--tag", "1", "--tag", @@ -455,7 +504,7 @@ mod tests { async fn test_handle_add_series_command() { let expected_add_series_body = AddSeriesBody { tvdb_id: 1, - title: String::new(), + title: "test".to_owned(), root_folder_path: "/test".to_owned(), quality_profile_id: 1, language_profile_id: 1, @@ -484,6 +533,7 @@ mod tests { let app_arc = Arc::new(Mutex::new(App::default())); let add_series_command = SonarrAddCommand::Series { tvdb_id: 1, + title: "test".to_owned(), root_folder_path: "/test".to_owned(), quality_profile_id: 1, language_profile_id: 1, From da3bb795b73621bc6a57eac5e2530786c95b0124 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Sun, 24 Nov 2024 14:54:41 -0700 Subject: [PATCH 101/119] feat(network): Support for searching for new series --- src/models/sonarr_models.rs | 2 + src/models/sonarr_models_tests.rs | 22 +++- src/network/radarr_network_tests.rs | 54 ++++---- src/network/sonarr_network.rs | 72 ++++++++++- src/network/sonarr_network_tests.rs | 188 +++++++++++++++++++++++++++- 5 files changed, 304 insertions(+), 34 deletions(-) diff --git a/src/models/sonarr_models.rs b/src/models/sonarr_models.rs index 46a4d04..8b01276 100644 --- a/src/models/sonarr_models.rs +++ b/src/models/sonarr_models.rs @@ -599,6 +599,7 @@ impl Display for SonarrTaskName { #[serde(untagged)] #[allow(clippy::large_enum_variant)] pub enum SonarrSerdeable { + AddSeriesSearchResults(Vec), BlocklistResponse(BlocklistResponse), DownloadsResponse(DownloadsResponse), DiskSpaces(Vec), @@ -641,6 +642,7 @@ impl From<()> for SonarrSerdeable { serde_enum_from!( SonarrSerdeable { + AddSeriesSearchResults(Vec), BlocklistResponse(BlocklistResponse), DownloadsResponse(DownloadsResponse), DiskSpaces(Vec), diff --git a/src/models/sonarr_models_tests.rs b/src/models/sonarr_models_tests.rs index 9171342..87ac76d 100644 --- a/src/models/sonarr_models_tests.rs +++ b/src/models/sonarr_models_tests.rs @@ -10,9 +10,10 @@ mod tests { RootFolder, SecurityConfig, Tag, Update, }, sonarr_models::{ - BlocklistItem, BlocklistResponse, DownloadRecord, DownloadsResponse, Episode, - IndexerSettings, Series, SeriesMonitor, SeriesStatus, SeriesType, SonarrHistoryEventType, - SonarrHistoryItem, SonarrRelease, SonarrSerdeable, SonarrTask, SonarrTaskName, SystemStatus, + AddSeriesSearchResult, BlocklistItem, BlocklistResponse, DownloadRecord, DownloadsResponse, + Episode, IndexerSettings, Series, SeriesMonitor, SeriesStatus, SeriesType, + SonarrHistoryEventType, SonarrHistoryItem, SonarrRelease, SonarrSerdeable, SonarrTask, + SonarrTaskName, SystemStatus, }, EnumDisplayStyle, Serdeable, }; @@ -327,6 +328,21 @@ mod tests { ); } + #[test] + fn test_sonarr_serdeable_from_add_series_search_results() { + let add_series_search_results = vec![AddSeriesSearchResult { + tvdb_id: 1, + ..AddSeriesSearchResult::default() + }]; + + let sonarr_serdeable: SonarrSerdeable = add_series_search_results.clone().into(); + + assert_eq!( + sonarr_serdeable, + SonarrSerdeable::AddSeriesSearchResults(add_series_search_results) + ); + } + #[test] fn test_sonarr_serdeable_from_blocklist_response() { let blocklist_response = BlocklistResponse { diff --git a/src/network/radarr_network_tests.rs b/src/network/radarr_network_tests.rs index 5093477..1fb3c3b 100644 --- a/src/network/radarr_network_tests.rs +++ b/src/network/radarr_network_tests.rs @@ -750,33 +750,6 @@ mod test { } } - #[tokio::test] - async fn test_handle_start_radarr_task_event_uses_provided_task_name() { - let response = json!({ "test": "test"}); - let (async_server, app_arc, _server) = mock_servarr_api( - RequestMethod::Post, - Some(json!({ - "name": "ApplicationCheckUpdate" - })), - Some(response.clone()), - None, - RadarrEvent::StartTask(None), - None, - None, - ) - .await; - let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); - - if let RadarrSerdeable::Value(value) = network - .handle_radarr_event(RadarrEvent::StartTask(Some(RadarrTaskName::default()))) - .await - .unwrap() - { - async_server.assert_async().await; - assert_eq!(value, response); - } - } - #[tokio::test] async fn test_handle_search_new_movie_event_no_results() { let (async_server, app_arc, _server) = mock_servarr_api( @@ -862,6 +835,33 @@ mod test { ); } + #[tokio::test] + async fn test_handle_start_radarr_task_event_uses_provided_task_name() { + let response = json!({ "test": "test"}); + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Post, + Some(json!({ + "name": "ApplicationCheckUpdate" + })), + Some(response.clone()), + None, + RadarrEvent::StartTask(None), + None, + None, + ) + .await; + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + if let RadarrSerdeable::Value(value) = network + .handle_radarr_event(RadarrEvent::StartTask(Some(RadarrTaskName::default()))) + .await + .unwrap() + { + async_server.assert_async().await; + assert_eq!(value, response); + } + } + #[tokio::test] async fn test_handle_test_radarr_indexer_event_error() { let indexer_details_json = json!({ diff --git a/src/network/sonarr_network.rs b/src/network/sonarr_network.rs index 14001ad..ce767af 100644 --- a/src/network/sonarr_network.rs +++ b/src/network/sonarr_network.rs @@ -1,7 +1,8 @@ -use anyhow::Result; +use anyhow::{anyhow, Result}; use indoc::formatdoc; -use log::{debug, info}; +use log::{debug, info, warn}; use serde_json::{json, Value}; +use urlencoding::encode; use crate::{ models::{ @@ -75,6 +76,7 @@ pub enum SonarrEvent { HealthCheck, ListSeries, MarkHistoryItemAsFailed(i64), + SearchNewSeries(Option), StartTask(Option), TestIndexer(Option), TestAllIndexers, @@ -125,6 +127,7 @@ impl NetworkResource for SonarrEvent { | SonarrEvent::ListSeries | SonarrEvent::GetSeriesDetails(_) | SonarrEvent::DeleteSeries(_) => "/series", + SonarrEvent::SearchNewSeries(_) => "/series/lookup", SonarrEvent::MarkHistoryItemAsFailed(_) => "/history/failed", SonarrEvent::TestIndexer(_) => "/indexer/test", SonarrEvent::TestAllIndexers => "/indexer/testall", @@ -265,6 +268,10 @@ impl<'a, 'b> Network<'a, 'b> { .mark_sonarr_history_item_as_failed(history_item_id) .await .map(SonarrSerdeable::from), + SonarrEvent::SearchNewSeries(query) => self + .search_sonarr_series(query) + .await + .map(SonarrSerdeable::from), SonarrEvent::StartTask(task_name) => self .start_sonarr_task(task_name) .await @@ -1549,6 +1556,67 @@ impl<'a, 'b> Network<'a, 'b> { .await } + async fn search_sonarr_series( + &mut self, + query: Option, + ) -> Result> { + info!("Searching for specific Sonarr series"); + let event = SonarrEvent::SearchNewSeries(None); + let search = if let Some(search_query) = query { + Ok(search_query.into()) + } else { + self + .app + .lock() + .await + .data + .sonarr_data + .add_series_search + .clone() + .ok_or(anyhow!("Encountered a race condition")) + }; + + match search { + Ok(search_string) => { + let request_props = self + .request_props_from( + event, + RequestMethod::Get, + None::<()>, + None, + Some(format!("term={}", encode(&search_string.text))), + ) + .await; + + self + .handle_request::<(), Vec>(request_props, |series_vec, mut app| { + if series_vec.is_empty() { + app.pop_and_push_navigation_stack( + ActiveSonarrBlock::AddSeriesEmptySearchResults.into(), + ); + } else if let Some(add_searched_seriess) = + app.data.sonarr_data.add_searched_series.as_mut() + { + add_searched_seriess.set_items(series_vec); + } else { + let mut add_searched_seriess = StatefulTable::default(); + add_searched_seriess.set_items(series_vec); + app.data.sonarr_data.add_searched_series = Some(add_searched_seriess); + } + }) + .await + } + Err(e) => { + warn!( + "Encountered a race condition: {e}\n \ + This is most likely caused by the user trying to navigate between modals rapidly. \ + Ignoring search request." + ); + Ok(Vec::default()) + } + } + } + async fn start_sonarr_task(&mut self, task: Option) -> Result { let event = SonarrEvent::StartTask(None); let task_name = if let Some(t_name) = task { diff --git a/src/network/sonarr_network_tests.rs b/src/network/sonarr_network_tests.rs index 82349ee..5acfa32 100644 --- a/src/network/sonarr_network_tests.rs +++ b/src/network/sonarr_network_tests.rs @@ -5,7 +5,7 @@ mod test { use bimap::BiMap; use chrono::{DateTime, Utc}; use indoc::formatdoc; - use mockito::Matcher; + use mockito::{Matcher, Server}; use pretty_assertions::{assert_eq, assert_str_eq}; use reqwest::Client; use rstest::rstest; @@ -20,7 +20,7 @@ mod test { SeriesMonitor, }; - use crate::app::App; + use crate::app::{App, ServarrConfig}; use crate::models::radarr_models::IndexerTestResult; use crate::models::servarr_data::modals::IndexerTestResultModalItem; use crate::models::servarr_data::sonarr::modals::{ @@ -251,6 +251,7 @@ mod test { #[case(SonarrEvent::GetTasks, "/system/task")] #[case(SonarrEvent::GetUpdates, "/update")] #[case(SonarrEvent::MarkHistoryItemAsFailed(0), "/history/failed")] + #[case(SonarrEvent::SearchNewSeries(None), "/series/lookup")] #[case(SonarrEvent::TestIndexer(None), "/indexer/test")] #[case(SonarrEvent::TestAllIndexers, "/indexer/testall")] fn test_resource(#[case] event: SonarrEvent, #[case] expected_uri: String) { @@ -4392,6 +4393,189 @@ mod test { async_server.assert_async().await; } + #[tokio::test] + async fn test_handle_search_new_series_event() { + let add_series_search_result_json = json!([{ + "tvdbId": 1234, + "title": "Test", + "status": "continuing", + "ended": false, + "overview": "New series blah blah blah", + "genres": ["cool", "family", "fun"], + "year": 2023, + "network": "Prime Video", + "runtime": 60, + "ratings": { "votes": 406744, "value": 8.4 }, + "statistics": { "seasonCount": 3 } + }]); + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(add_series_search_result_json), + None, + SonarrEvent::SearchNewSeries(None), + None, + Some("term=test%20term"), + ) + .await; + app_arc.lock().await.data.sonarr_data.add_series_search = Some("test term".into()); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + if let SonarrSerdeable::AddSeriesSearchResults(add_series_search_results) = network + .handle_sonarr_event(SonarrEvent::SearchNewSeries(None)) + .await + .unwrap() + { + async_server.assert_async().await; + assert!(app_arc + .lock() + .await + .data + .sonarr_data + .add_searched_series + .is_some()); + assert_eq!( + app_arc + .lock() + .await + .data + .sonarr_data + .add_searched_series + .as_ref() + .unwrap() + .items, + vec![add_series_search_result()] + ); + assert_eq!(add_series_search_results, vec![add_series_search_result()]); + } + } + + #[tokio::test] + async fn test_handle_search_new_series_event_uses_provided_query() { + let add_series_search_result_json = json!([{ + "tvdbId": 1234, + "title": "Test", + "status": "continuing", + "ended": false, + "overview": "New series blah blah blah", + "genres": ["cool", "family", "fun"], + "year": 2023, + "network": "Prime Video", + "runtime": 60, + "ratings": { "votes": 406744, "value": 8.4 }, + "statistics": { "seasonCount": 3 } + }]); + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(add_series_search_result_json), + None, + SonarrEvent::SearchNewSeries(None), + None, + Some("term=test%20term"), + ) + .await; + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + if let SonarrSerdeable::AddSeriesSearchResults(add_series_search_results) = network + .handle_sonarr_event(SonarrEvent::SearchNewSeries(Some("test term".into()))) + .await + .unwrap() + { + async_server.assert_async().await; + assert_eq!(add_series_search_results, vec![add_series_search_result()]); + } + } + + #[tokio::test] + async fn test_handle_search_new_series_event_no_results() { + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(json!([])), + None, + SonarrEvent::SearchNewSeries(None), + None, + Some("term=test%20term"), + ) + .await; + app_arc.lock().await.data.sonarr_data.add_series_search = Some("test term".into()); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + assert!(network + .handle_sonarr_event(SonarrEvent::SearchNewSeries(None)) + .await + .is_ok()); + + async_server.assert_async().await; + assert!(app_arc + .lock() + .await + .data + .sonarr_data + .add_searched_series + .is_none()); + assert_eq!( + app_arc.lock().await.get_current_route(), + &ActiveSonarrBlock::AddSeriesEmptySearchResults.into() + ); + } + + #[tokio::test] + async fn test_handle_search_new_series_event_no_panic_on_race_condition() { + let resource = format!( + "{}?term=test%20term", + SonarrEvent::SearchNewSeries(None).resource() + ); + let mut server = Server::new_async().await; + let mut async_server = server + .mock( + &RequestMethod::Get.to_string().to_uppercase(), + format!("/api/v3{resource}").as_str(), + ) + .match_header("X-Api-Key", "test1234"); + async_server = async_server.expect_at_most(0).create_async().await; + + let host = Some(server.host_with_port().split(':').collect::>()[0].to_owned()); + let port = Some( + server.host_with_port().split(':').collect::>()[1] + .parse() + .unwrap(), + ); + let mut app = App::default(); + let sonarr_config = ServarrConfig { + host, + port, + api_token: "test1234".to_owned(), + ..ServarrConfig::default() + }; + app.config.sonarr = Some(sonarr_config); + let app_arc = Arc::new(Mutex::new(app)); + app_arc + .lock() + .await + .push_navigation_stack(ActiveSonarrBlock::Series.into()); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + assert!(network + .handle_sonarr_event(SonarrEvent::SearchNewSeries(None)) + .await + .is_ok()); + + async_server.assert_async().await; + assert!(app_arc + .lock() + .await + .data + .sonarr_data + .add_searched_series + .is_none()); + assert_eq!( + app_arc.lock().await.get_current_route(), + &ActiveSonarrBlock::Series.into() + ); + } + #[tokio::test] async fn test_handle_start_sonarr_task_event() { let response = json!({ "test": "test"}); From 45542cd3a9440b0e945d6e5264adfc3b16a2d965 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Sun, 24 Nov 2024 14:58:30 -0700 Subject: [PATCH 102/119] feat(cli): Support for searching for new series to add to Sonarr --- src/cli/radarr/radarr_command_tests.rs | 4 +- src/cli/sonarr/mod.rs | 16 ++++++++ src/cli/sonarr/sonarr_command_tests.rs | 51 ++++++++++++++++++++++++++ 3 files changed, 69 insertions(+), 2 deletions(-) diff --git a/src/cli/radarr/radarr_command_tests.rs b/src/cli/radarr/radarr_command_tests.rs index 546dd70..88e541b 100644 --- a/src/cli/radarr/radarr_command_tests.rs +++ b/src/cli/radarr/radarr_command_tests.rs @@ -129,7 +129,7 @@ mod tests { assert!(result.is_ok()); } - #[rstest] + #[test] fn test_search_new_movie_requires_query() { let result = Cli::command().try_get_matches_from(["managarr", "radarr", "search-new-movie"]); @@ -153,7 +153,7 @@ mod tests { assert!(result.is_ok()); } - #[rstest] + #[test] fn test_start_task_requires_task_name() { let result = Cli::command().try_get_matches_from(["managarr", "radarr", "start-task"]); diff --git a/src/cli/sonarr/mod.rs b/src/cli/sonarr/mod.rs index 573697b..9d9818d 100644 --- a/src/cli/sonarr/mod.rs +++ b/src/cli/sonarr/mod.rs @@ -85,6 +85,15 @@ pub enum SonarrCommand { )] history_item_id: i64, }, + #[command(about = "Search for a new series to add to Sonarr")] + SearchNewSeries { + #[arg( + long, + help = "The title of the series you want to search for", + required = true + )] + query: String, + }, #[command(about = "Start the specified Sonarr task")] StartTask { #[arg( @@ -195,6 +204,13 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, SonarrCommand> for SonarrCliHandler<'a, ' .await?; "Sonarr history item marked as 'failed'".to_owned() } + SonarrCommand::SearchNewSeries { query } => { + let resp = self + .network + .handle_network_event(SonarrEvent::SearchNewSeries(Some(query)).into()) + .await?; + serde_json::to_string_pretty(&resp)? + } SonarrCommand::StartTask { task_name } => { let resp = self .network diff --git a/src/cli/sonarr/sonarr_command_tests.rs b/src/cli/sonarr/sonarr_command_tests.rs index 822aa69..60ccec5 100644 --- a/src/cli/sonarr/sonarr_command_tests.rs +++ b/src/cli/sonarr/sonarr_command_tests.rs @@ -57,6 +57,30 @@ mod tests { assert!(result.is_ok()); } + #[test] + fn test_search_new_series_requires_query() { + let result = Cli::command().try_get_matches_from(["managarr", "sonarr", "search-new-series"]); + + assert!(result.is_err()); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_search_new_series_requirements_satisfied() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "sonarr", + "search-new-series", + "--query", + "halo", + ]); + + assert!(result.is_ok()); + } + #[test] fn test_start_task_requires_task_name() { let result = Cli::command().try_get_matches_from(["managarr", "sonarr", "start-task"]); @@ -434,6 +458,33 @@ mod tests { assert!(result.is_ok()); } + #[tokio::test] + async fn test_search_new_series_command() { + let expected_search_query = "halo".to_owned(); + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::( + SonarrEvent::SearchNewSeries(Some(expected_search_query)).into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Sonarr(SonarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::default())); + let search_new_series_command = SonarrCommand::SearchNewSeries { + query: "halo".to_owned(), + }; + + let result = SonarrCliHandler::with(&app_arc, search_new_series_command, &mut mock_network) + .handle() + .await; + + assert!(result.is_ok()); + } + #[tokio::test] async fn test_start_task_command() { let expected_task_name = SonarrTaskName::ApplicationUpdateCheck; From 1e3141e4ee57f9037c4964574499818e2e315cb1 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Mon, 25 Nov 2024 13:58:34 -0700 Subject: [PATCH 103/119] feat(network): Support for editing all sonarr indexer settings --- src/network/radarr_network.rs | 7 ++- src/network/radarr_network_tests.rs | 4 +- src/network/sonarr_network.rs | 46 +++++++++++++- src/network/sonarr_network_tests.rs | 93 ++++++++++++++++++++++++++++- 4 files changed, 144 insertions(+), 6 deletions(-) diff --git a/src/network/radarr_network.rs b/src/network/radarr_network.rs index 3e3c124..efb6823 100644 --- a/src/network/radarr_network.rs +++ b/src/network/radarr_network.rs @@ -181,7 +181,7 @@ impl<'a, 'b> Network<'a, 'b> { .await .map(RadarrSerdeable::from), RadarrEvent::EditAllIndexerSettings(params) => self - .edit_all_indexer_settings(params) + .edit_all_radarr_indexer_settings(params) .await .map(RadarrSerdeable::from), RadarrEvent::EditCollection(params) => self @@ -710,7 +710,10 @@ impl<'a, 'b> Network<'a, 'b> { .await } - async fn edit_all_indexer_settings(&mut self, params: Option) -> Result { + async fn edit_all_radarr_indexer_settings( + &mut self, + params: Option, + ) -> Result { info!("Updating Radarr indexer settings"); let event = RadarrEvent::EditAllIndexerSettings(None); diff --git a/src/network/radarr_network_tests.rs b/src/network/radarr_network_tests.rs index 1fb3c3b..fc71755 100644 --- a/src/network/radarr_network_tests.rs +++ b/src/network/radarr_network_tests.rs @@ -3711,7 +3711,7 @@ mod test { } #[tokio::test] - async fn test_handle_edit_all_indexer_settings_event() { + async fn test_handle_edit_all_radarr_indexer_settings_event() { let indexer_settings_json = json!({ "minimumAge": 0, "maximumSize": 0, @@ -3753,7 +3753,7 @@ mod test { } #[tokio::test] - async fn test_handle_edit_all_indexer_settings_event_uses_provided_settings() { + async fn test_handle_edit_all_radarr_indexer_settings_event_uses_provided_settings() { let indexer_settings_json = json!({ "minimumAge": 0, "maximumSize": 0, diff --git a/src/network/sonarr_network.rs b/src/network/sonarr_network.rs index ce767af..c205179 100644 --- a/src/network/sonarr_network.rs +++ b/src/network/sonarr_network.rs @@ -49,6 +49,7 @@ pub enum SonarrEvent { DeleteSeries(Option), DeleteTag(i64), DownloadRelease(SonarrReleaseDownloadBody), + EditAllIndexerSettings(Option), GetAllIndexerSettings, GetBlocklist, GetDownloads, @@ -95,7 +96,9 @@ impl NetworkResource for SonarrEvent { SonarrEvent::ClearBlocklist => "/blocklist/bulk", SonarrEvent::DownloadRelease(_) => "/release", SonarrEvent::DeleteBlocklistItem(_) => "/blocklist", - SonarrEvent::GetAllIndexerSettings => "/config/indexer", + SonarrEvent::GetAllIndexerSettings | SonarrEvent::EditAllIndexerSettings(_) => { + "/config/indexer" + } SonarrEvent::GetBlocklist => "/blocklist?page=1&pageSize=10000", SonarrEvent::GetDownloads | SonarrEvent::DeleteDownload(_) => "/queue", SonarrEvent::GetEpisodes(_) | SonarrEvent::GetEpisodeDetails(_) => "/episode", @@ -191,6 +194,10 @@ impl<'a, 'b> Network<'a, 'b> { .download_sonarr_release(sonarr_release_download_body) .await .map(SonarrSerdeable::from), + SonarrEvent::EditAllIndexerSettings(params) => self + .edit_all_sonarr_indexer_settings(params) + .await + .map(SonarrSerdeable::from), SonarrEvent::GetBlocklist => self.get_sonarr_blocklist().await.map(SonarrSerdeable::from), SonarrEvent::GetDownloads => self.get_sonarr_downloads().await.map(SonarrSerdeable::from), SonarrEvent::GetEpisodes(series_id) => self @@ -714,6 +721,43 @@ impl<'a, 'b> Network<'a, 'b> { .await } + async fn edit_all_sonarr_indexer_settings( + &mut self, + params: Option, + ) -> Result { + info!("Updating Sonarr indexer settings"); + let event = SonarrEvent::EditAllIndexerSettings(None); + + let body = if let Some(indexer_settings) = params { + indexer_settings + } else { + self + .app + .lock() + .await + .data + .sonarr_data + .indexer_settings + .as_ref() + .unwrap() + .clone() + }; + + debug!("Indexer settings body: {body:?}"); + + let request_props = self + .request_props_from(event, RequestMethod::Put, Some(body), None, None) + .await; + + let resp = self + .handle_request::(request_props, |_, _| {}) + .await; + + self.app.lock().await.data.sonarr_data.indexer_settings = None; + + resp + } + async fn get_all_sonarr_indexer_settings(&mut self) -> Result { info!("Fetching Sonarr indexer settings"); let event = SonarrEvent::GetAllIndexerSettings; diff --git a/src/network/sonarr_network_tests.rs b/src/network/sonarr_network_tests.rs index 5acfa32..55f5861 100644 --- a/src/network/sonarr_network_tests.rs +++ b/src/network/sonarr_network_tests.rs @@ -17,7 +17,7 @@ mod test { use crate::models::sonarr_models::{ AddSeriesBody, AddSeriesOptions, AddSeriesSearchResult, AddSeriesSearchResultStatistics, - SeriesMonitor, + IndexerSettings, SeriesMonitor, }; use crate::app::{App, ServarrConfig}; @@ -135,6 +135,17 @@ mod test { "id": 1 }"#; + #[rstest] + fn test_resource_all_indexer_settings( + #[values( + SonarrEvent::GetAllIndexerSettings, + SonarrEvent::EditAllIndexerSettings(None) + )] + event: SonarrEvent, + ) { + assert_str_eq!(event.resource(), "/config/indexer"); + } + #[rstest] fn test_resource_episode( #[values(SonarrEvent::GetEpisodes(None), SonarrEvent::GetEpisodeDetails(None))] @@ -967,6 +978,76 @@ mod test { async_server.assert_async().await; } + #[tokio::test] + async fn test_handle_edit_all_indexer_settings_event() { + let indexer_settings_json = json!({ + "id": 1, + "minimumAge": 1, + "maximumSize": 12345, + "retention": 1, + "rssSyncInterval": 60 + }); + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Put, + Some(indexer_settings_json), + None, + None, + SonarrEvent::EditAllIndexerSettings(None), + None, + None, + ) + .await; + + app_arc.lock().await.data.sonarr_data.indexer_settings = Some(indexer_settings()); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + assert!(network + .handle_sonarr_event(SonarrEvent::EditAllIndexerSettings(None)) + .await + .is_ok()); + + async_server.assert_async().await; + assert!(app_arc + .lock() + .await + .data + .sonarr_data + .indexer_settings + .is_none()); + } + + #[tokio::test] + async fn test_handle_edit_all_indexer_settings_event_uses_provided_settings() { + let indexer_settings_json = json!({ + "id": 1, + "minimumAge": 1, + "maximumSize": 12345, + "retention": 1, + "rssSyncInterval": 60 + }); + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Put, + Some(indexer_settings_json), + None, + None, + SonarrEvent::EditAllIndexerSettings(None), + None, + None, + ) + .await; + + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + assert!(network + .handle_sonarr_event(SonarrEvent::EditAllIndexerSettings( + Some(indexer_settings()) + )) + .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) { @@ -5743,6 +5824,16 @@ mod test { } } + fn indexer_settings() -> IndexerSettings { + IndexerSettings { + id: 1, + minimum_age: 1, + retention: 1, + maximum_size: 12345, + rss_sync_interval: 60, + } + } + fn language() -> Language { Language { id: 1, From 4c7e8f0cf60094ee5f8278fb782408c4dd7cf472 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Mon, 25 Nov 2024 14:01:47 -0700 Subject: [PATCH 104/119] feat(models): Added the ActiveSonarrBlocks for editing all indexer settings --- src/models/servarr_data/sonarr/sonarr_data.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/models/servarr_data/sonarr/sonarr_data.rs b/src/models/servarr_data/sonarr/sonarr_data.rs index acf0552..11baf20 100644 --- a/src/models/servarr_data/sonarr/sonarr_data.rs +++ b/src/models/servarr_data/sonarr/sonarr_data.rs @@ -146,6 +146,11 @@ pub enum ActiveSonarrBlock { HistoryDetails, HistorySortPrompt, Indexers, + IndexerSettingsConfirmPrompt, + IndexerSettingsMaximumSizeInput, + IndexerSettingsMinimumAgeInput, + IndexerSettingsRetentionInput, + IndexerSettingsRssSyncIntervalInput, ManualEpisodeSearch, ManualEpisodeSearchConfirmPrompt, ManualEpisodeSearchSortPrompt, From 3968983002ce50de8473677befd0f0f7c68a7ab7 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Mon, 25 Nov 2024 14:25:10 -0700 Subject: [PATCH 105/119] feat(cli): Support for editing all indexer settings in Sonarr --- src/cli/radarr/radarr_command_tests.rs | 14 +- src/cli/sonarr/edit_command_handler.rs | 120 +++++++++++ src/cli/sonarr/edit_command_handler_tests.rs | 196 ++++++++++++++++++ .../manual_search_command_handler_tests.rs | 5 +- src/cli/sonarr/mod.rs | 12 ++ src/cli/sonarr/sonarr_command_tests.rs | 66 +++++- 6 files changed, 399 insertions(+), 14 deletions(-) create mode 100644 src/cli/sonarr/edit_command_handler.rs create mode 100644 src/cli/sonarr/edit_command_handler_tests.rs diff --git a/src/cli/radarr/radarr_command_tests.rs b/src/cli/radarr/radarr_command_tests.rs index 88e541b..5fd14a1 100644 --- a/src/cli/radarr/radarr_command_tests.rs +++ b/src/cli/radarr/radarr_command_tests.rs @@ -31,7 +31,7 @@ mod tests { assert!(result.is_ok()); } - #[rstest] + #[test] fn test_download_release_requires_movie_id() { let result = Cli::command().try_get_matches_from([ "managarr", @@ -50,7 +50,7 @@ mod tests { ); } - #[rstest] + #[test] fn test_download_release_requires_guid() { let result = Cli::command().try_get_matches_from([ "managarr", @@ -69,7 +69,7 @@ mod tests { ); } - #[rstest] + #[test] fn test_download_release_requires_indexer_id() { let result = Cli::command().try_get_matches_from([ "managarr", @@ -105,7 +105,7 @@ mod tests { assert!(result.is_ok()); } - #[rstest] + #[test] fn test_manual_search_requires_movie_id() { let result = Cli::command().try_get_matches_from(["managarr", "radarr", "manual-search"]); @@ -164,7 +164,7 @@ mod tests { ); } - #[rstest] + #[test] fn test_start_task_task_name_validation() { let result = Cli::command().try_get_matches_from([ "managarr", @@ -191,7 +191,7 @@ mod tests { assert!(result.is_ok()); } - #[rstest] + #[test] fn test_test_indexer_requires_indexer_id() { let result = Cli::command().try_get_matches_from(["managarr", "radarr", "test-indexer"]); @@ -215,7 +215,7 @@ mod tests { assert!(result.is_ok()); } - #[rstest] + #[test] fn test_trigger_automatic_search_requires_movie_id() { let result = Cli::command().try_get_matches_from(["managarr", "radarr", "trigger-automatic-search"]); diff --git a/src/cli/sonarr/edit_command_handler.rs b/src/cli/sonarr/edit_command_handler.rs new file mode 100644 index 0000000..fe04b26 --- /dev/null +++ b/src/cli/sonarr/edit_command_handler.rs @@ -0,0 +1,120 @@ +use std::sync::Arc; + +use anyhow::Result; +use clap::{ArgGroup, Subcommand}; +use tokio::sync::Mutex; + +use crate::{ + app::App, + cli::{CliCommandHandler, Command}, + models::{ + sonarr_models::{IndexerSettings, SonarrSerdeable}, + Serdeable, + }, + network::{sonarr_network::SonarrEvent, NetworkTrait}, +}; + +use super::SonarrCommand; + +#[cfg(test)] +#[path = "edit_command_handler_tests.rs"] +mod edit_command_handler_tests; + +#[derive(Debug, Clone, PartialEq, Eq, Subcommand)] +pub enum SonarrEditCommand { + #[command( + about = "Edit and indexer settings that apply to all indexers", + group( + ArgGroup::new("edit_settings") + .args([ + "maximum_size", + "minimum_age", + "retention", + "rss_sync_interval", + ]).required(true) + .multiple(true)) + )] + AllIndexerSettings { + #[arg( + long, + help = "The maximum size for a release to be grabbed in MB. Set to zero to set to unlimited" + )] + maximum_size: Option, + #[arg( + long, + help = "Usenet only: Minimum age in minutes of NZBs before they are grabbed. Use this to give new releases time to propagate to your usenet provider." + )] + minimum_age: Option, + #[arg( + long, + help = "Usenet only: The retention time in days to retain releases. Set to zero to set for unlimited retention" + )] + retention: Option, + #[arg( + long, + help = "The RSS sync interval in minutes. Set to zero to disable (this will stop all automatic release grabbing)" + )] + rss_sync_interval: Option, + }, +} + +impl From for Command { + fn from(value: SonarrEditCommand) -> Self { + Command::Sonarr(SonarrCommand::Edit(value)) + } +} + +pub(super) struct SonarrEditCommandHandler<'a, 'b> { + _app: &'a Arc>>, + command: SonarrEditCommand, + network: &'a mut dyn NetworkTrait, +} + +impl<'a, 'b> CliCommandHandler<'a, 'b, SonarrEditCommand> for SonarrEditCommandHandler<'a, 'b> { + fn with( + _app: &'a Arc>>, + command: SonarrEditCommand, + network: &'a mut dyn NetworkTrait, + ) -> Self { + SonarrEditCommandHandler { + _app, + command, + network, + } + } + + async fn handle(self) -> Result { + let result = match self.command { + SonarrEditCommand::AllIndexerSettings { + maximum_size, + minimum_age, + retention, + rss_sync_interval, + } => { + if let Serdeable::Sonarr(SonarrSerdeable::IndexerSettings(previous_indexer_settings)) = self + .network + .handle_network_event(SonarrEvent::GetAllIndexerSettings.into()) + .await? + { + let params = IndexerSettings { + id: 1, + maximum_size: maximum_size.unwrap_or(previous_indexer_settings.maximum_size), + minimum_age: minimum_age.unwrap_or(previous_indexer_settings.minimum_age), + retention: retention.unwrap_or(previous_indexer_settings.retention), + rss_sync_interval: rss_sync_interval + .unwrap_or(previous_indexer_settings.rss_sync_interval), + }; + self + .network + .handle_network_event(SonarrEvent::EditAllIndexerSettings(Some(params)).into()) + .await?; + "All indexer settings updated".to_owned() + } else { + String::new() + } + } + }; + + Ok(result) + } +} diff --git a/src/cli/sonarr/edit_command_handler_tests.rs b/src/cli/sonarr/edit_command_handler_tests.rs new file mode 100644 index 0000000..e8bb470 --- /dev/null +++ b/src/cli/sonarr/edit_command_handler_tests.rs @@ -0,0 +1,196 @@ +#[cfg(test)] +mod tests { + use crate::cli::{ + sonarr::{edit_command_handler::SonarrEditCommand, SonarrCommand}, + Command, + }; + + #[test] + fn test_sonarr_edit_command_from() { + let command = SonarrEditCommand::AllIndexerSettings { + maximum_size: None, + minimum_age: None, + retention: None, + rss_sync_interval: None, + }; + + let result = Command::from(command.clone()); + + assert_eq!(result, Command::Sonarr(SonarrCommand::Edit(command))); + } + + mod cli { + use crate::Cli; + + use super::*; + use clap::{error::ErrorKind, CommandFactory, Parser}; + use pretty_assertions::assert_eq; + use rstest::rstest; + + #[test] + fn test_edit_all_indexer_settings_requires_arguments() { + let result = + Cli::command().try_get_matches_from(["managarr", "sonarr", "edit", "all-indexer-settings"]); + + assert!(result.is_err()); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[rstest] + fn test_edit_all_indexer_settings_assert_argument_flags_require_args( + #[values( + "--maximum-size", + "--minimum-age", + "--retention", + "--rss-sync-interval" + )] + flag: &str, + ) { + let result = Cli::command().try_get_matches_from([ + "managarr", + "sonarr", + "edit", + "all-indexer-settings", + flag, + ]); + + assert!(result.is_err()); + assert_eq!(result.unwrap_err().kind(), ErrorKind::InvalidValue); + } + + #[test] + fn test_edit_all_indexer_settings_only_requires_at_least_one_argument() { + let expected_args = SonarrEditCommand::AllIndexerSettings { + maximum_size: Some(1), + minimum_age: None, + retention: None, + rss_sync_interval: None, + }; + let result = Cli::try_parse_from([ + "managarr", + "sonarr", + "edit", + "all-indexer-settings", + "--maximum-size", + "1", + ]); + + assert!(result.is_ok()); + + if let Some(Command::Sonarr(SonarrCommand::Edit(edit_command))) = result.unwrap().command { + assert_eq!(edit_command, expected_args); + } + } + + #[test] + fn test_edit_all_indexer_settings_all_arguments_defined() { + let expected_args = SonarrEditCommand::AllIndexerSettings { + maximum_size: Some(1), + minimum_age: Some(1), + retention: Some(1), + rss_sync_interval: Some(1), + }; + let result = Cli::try_parse_from([ + "managarr", + "sonarr", + "edit", + "all-indexer-settings", + "--maximum-size", + "1", + "--minimum-age", + "1", + "--retention", + "1", + "--rss-sync-interval", + "1", + ]); + + assert!(result.is_ok()); + + if let Some(Command::Sonarr(SonarrCommand::Edit(edit_command))) = result.unwrap().command { + assert_eq!(edit_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::edit_command_handler::{SonarrEditCommand, SonarrEditCommandHandler}, + CliCommandHandler, + }, + models::{ + sonarr_models::{IndexerSettings, SonarrSerdeable}, + Serdeable, + }, + network::{sonarr_network::SonarrEvent, MockNetworkTrait, NetworkEvent}, + }; + + #[tokio::test] + async fn test_handle_edit_all_indexer_settings_command() { + let expected_edit_all_indexer_settings = IndexerSettings { + id: 1, + maximum_size: 1, + minimum_age: 1, + retention: 1, + rss_sync_interval: 1, + }; + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::( + SonarrEvent::GetAllIndexerSettings.into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Sonarr(SonarrSerdeable::IndexerSettings( + IndexerSettings { + id: 1, + maximum_size: 2, + minimum_age: 2, + retention: 2, + rss_sync_interval: 2, + }, + ))) + }); + mock_network + .expect_handle_network_event() + .with(eq::( + SonarrEvent::EditAllIndexerSettings(Some(expected_edit_all_indexer_settings)).into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Sonarr(SonarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::default())); + let edit_all_indexer_settings_command = SonarrEditCommand::AllIndexerSettings { + maximum_size: Some(1), + minimum_age: Some(1), + retention: Some(1), + rss_sync_interval: Some(1), + }; + + let result = SonarrEditCommandHandler::with( + &app_arc, + edit_all_indexer_settings_command, + &mut mock_network, + ) + .handle() + .await; + + assert!(result.is_ok()); + } + } +} diff --git a/src/cli/sonarr/manual_search_command_handler_tests.rs b/src/cli/sonarr/manual_search_command_handler_tests.rs index 26dc760..53b26ad 100644 --- a/src/cli/sonarr/manual_search_command_handler_tests.rs +++ b/src/cli/sonarr/manual_search_command_handler_tests.rs @@ -24,9 +24,8 @@ mod tests { use super::*; use clap::error::ErrorKind; use pretty_assertions::assert_eq; - use rstest::rstest; - #[rstest] + #[test] fn test_manual_season_search_requires_series_id() { let result = Cli::command().try_get_matches_from([ "managarr", @@ -44,7 +43,7 @@ mod tests { ); } - #[rstest] + #[test] fn test_manual_season_search_requires_season_number() { let result = Cli::command().try_get_matches_from([ "managarr", diff --git a/src/cli/sonarr/mod.rs b/src/cli/sonarr/mod.rs index 9d9818d..1cf214d 100644 --- a/src/cli/sonarr/mod.rs +++ b/src/cli/sonarr/mod.rs @@ -5,6 +5,7 @@ use anyhow::Result; use clap::Subcommand; use delete_command_handler::{SonarrDeleteCommand, SonarrDeleteCommandHandler}; use download_command_handler::{SonarrDownloadCommand, SonarrDownloadCommandHandler}; +use edit_command_handler::{SonarrEditCommand, SonarrEditCommandHandler}; use get_command_handler::{SonarrGetCommand, SonarrGetCommandHandler}; use list_command_handler::{SonarrListCommand, SonarrListCommandHandler}; use manual_search_command_handler::{SonarrManualSearchCommand, SonarrManualSearchCommandHandler}; @@ -25,6 +26,7 @@ use super::{CliCommandHandler, Command}; mod add_command_handler; mod delete_command_handler; mod download_command_handler; +mod edit_command_handler; mod get_command_handler; mod list_command_handler; mod manual_search_command_handler; @@ -47,6 +49,11 @@ pub enum SonarrCommand { about = "Commands to delete resources from your Sonarr instance" )] Delete(SonarrDeleteCommand), + #[command( + subcommand, + about = "Commands to edit resources in your Sonarr instance" + )] + Edit(SonarrEditCommand), #[command( subcommand, about = "Commands to fetch details of the resources in your Sonarr instance" @@ -152,6 +159,11 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, SonarrCommand> for SonarrCliHandler<'a, ' .handle() .await? } + SonarrCommand::Edit(edit_command) => { + SonarrEditCommandHandler::with(self.app, edit_command, self.network) + .handle() + .await? + } SonarrCommand::Download(download_command) => { SonarrDownloadCommandHandler::with(self.app, download_command, self.network) .handle() diff --git a/src/cli/sonarr/sonarr_command_tests.rs b/src/cli/sonarr/sonarr_command_tests.rs index 60ccec5..4490d23 100644 --- a/src/cli/sonarr/sonarr_command_tests.rs +++ b/src/cli/sonarr/sonarr_command_tests.rs @@ -156,8 +156,8 @@ mod tests { cli::{ sonarr::{ add_command_handler::SonarrAddCommand, delete_command_handler::SonarrDeleteCommand, - download_command_handler::SonarrDownloadCommand, get_command_handler::SonarrGetCommand, - list_command_handler::SonarrListCommand, + download_command_handler::SonarrDownloadCommand, edit_command_handler::SonarrEditCommand, + get_command_handler::SonarrGetCommand, list_command_handler::SonarrListCommand, manual_search_command_handler::SonarrManualSearchCommand, refresh_command_handler::SonarrRefreshCommand, trigger_automatic_search_command_handler::SonarrTriggerAutomaticSearchCommand, @@ -167,8 +167,8 @@ mod tests { }, models::{ sonarr_models::{ - BlocklistItem, BlocklistResponse, Series, SonarrReleaseDownloadBody, SonarrSerdeable, - SonarrTaskName, + BlocklistItem, BlocklistResponse, IndexerSettings, Series, SonarrReleaseDownloadBody, + SonarrSerdeable, SonarrTaskName, }, Serdeable, }, @@ -330,6 +330,64 @@ mod tests { assert!(result.is_ok()); } + #[tokio::test] + async fn test_sonarr_cli_handler_delegates_edit_commands_to_the_edit_command_handler() { + let expected_edit_all_indexer_settings = IndexerSettings { + id: 1, + maximum_size: 1, + minimum_age: 1, + retention: 1, + rss_sync_interval: 1, + }; + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::( + SonarrEvent::GetAllIndexerSettings.into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Sonarr(SonarrSerdeable::IndexerSettings( + IndexerSettings { + id: 1, + maximum_size: 2, + minimum_age: 2, + retention: 2, + rss_sync_interval: 2, + }, + ))) + }); + mock_network + .expect_handle_network_event() + .with(eq::( + SonarrEvent::EditAllIndexerSettings(Some(expected_edit_all_indexer_settings)).into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Sonarr(SonarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::default())); + let edit_all_indexer_settings_command = + SonarrCommand::Edit(SonarrEditCommand::AllIndexerSettings { + maximum_size: Some(1), + minimum_age: Some(1), + retention: Some(1), + rss_sync_interval: Some(1), + }); + + let result = SonarrCliHandler::with( + &app_arc, + edit_all_indexer_settings_command, + &mut mock_network, + ) + .handle() + .await; + + assert!(result.is_ok()); + } + #[tokio::test] async fn test_sonarr_cli_handler_delegates_manual_search_commands_to_the_manual_search_command_handler( ) { From 4f86cce4979e303befb9d400a7536b30829342dc Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Mon, 25 Nov 2024 14:43:53 -0700 Subject: [PATCH 106/119] feat(network): Support for deleting an episode file from disk in Sonarr --- src/models/servarr_data/sonarr/sonarr_data.rs | 1 - src/network/sonarr_network.rs | 42 ++++++++++ src/network/sonarr_network_tests.rs | 78 +++++++++++++++++++ 3 files changed, 120 insertions(+), 1 deletion(-) diff --git a/src/models/servarr_data/sonarr/sonarr_data.rs b/src/models/servarr_data/sonarr/sonarr_data.rs index 11baf20..bd2962f 100644 --- a/src/models/servarr_data/sonarr/sonarr_data.rs +++ b/src/models/servarr_data/sonarr/sonarr_data.rs @@ -127,7 +127,6 @@ pub enum ActiveSonarrBlock { DeleteSeriesToggleAddListExclusion, DeleteSeriesToggleDeleteFile, Downloads, - EditEpisodePrompt, EditIndexerPrompt, EditSeriesPrompt, EpisodeDetails, diff --git a/src/network/sonarr_network.rs b/src/network/sonarr_network.rs index c205179..27ba7fc 100644 --- a/src/network/sonarr_network.rs +++ b/src/network/sonarr_network.rs @@ -44,6 +44,7 @@ pub enum SonarrEvent { ClearBlocklist, DeleteBlocklistItem(Option), DeleteDownload(Option), + DeleteEpisodeFile(Option), DeleteIndexer(Option), DeleteRootFolder(Option), DeleteSeries(Option), @@ -99,6 +100,7 @@ impl NetworkResource for SonarrEvent { SonarrEvent::GetAllIndexerSettings | SonarrEvent::EditAllIndexerSettings(_) => { "/config/indexer" } + SonarrEvent::DeleteEpisodeFile(_) => "/episodefile", SonarrEvent::GetBlocklist => "/blocklist?page=1&pageSize=10000", SonarrEvent::GetDownloads | SonarrEvent::DeleteDownload(_) => "/queue", SonarrEvent::GetEpisodes(_) | SonarrEvent::GetEpisodeDetails(_) => "/episode", @@ -175,6 +177,10 @@ impl<'a, 'b> Network<'a, 'b> { .delete_sonarr_download(download_id) .await .map(SonarrSerdeable::from), + SonarrEvent::DeleteEpisodeFile(episode_file_id) => self + .delete_sonarr_episode_file(episode_file_id) + .await + .map(SonarrSerdeable::from), SonarrEvent::DeleteIndexer(indexer_id) => self .delete_sonarr_indexer(indexer_id) .await @@ -532,6 +538,42 @@ impl<'a, 'b> Network<'a, 'b> { .await } + async fn delete_sonarr_episode_file(&mut self, episode_file_id: Option) -> Result<()> { + let event = SonarrEvent::DeleteEpisodeFile(None); + let id = if let Some(ep_id) = episode_file_id { + ep_id + } else { + self + .app + .lock() + .await + .data + .sonarr_data + .season_details_modal + .as_ref() + .expect("Season details have not been loaded") + .episodes + .current_selection() + .episode_file_id + }; + + info!("Deleting Sonarr episode file for episode file 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 delete_sonarr_download(&mut self, download_id: Option) -> Result<()> { let event = SonarrEvent::DeleteDownload(None); let id = if let Some(dl_id) = download_id { diff --git a/src/network/sonarr_network_tests.rs b/src/network/sonarr_network_tests.rs index 55f5861..4952610 100644 --- a/src/network/sonarr_network_tests.rs +++ b/src/network/sonarr_network_tests.rs @@ -251,6 +251,7 @@ mod test { #[rstest] #[case(SonarrEvent::ClearBlocklist, "/blocklist/bulk")] #[case(SonarrEvent::DeleteBlocklistItem(None), "/blocklist")] + #[case(SonarrEvent::DeleteEpisodeFile(None), "/episodefile")] #[case(SonarrEvent::HealthCheck, "/health")] #[case(SonarrEvent::GetBlocklist, "/blocklist?page=1&pageSize=10000")] #[case(SonarrEvent::GetDiskSpace, "/diskspace")] @@ -712,6 +713,83 @@ mod test { async_server.assert_async().await; } + #[tokio::test] + async fn test_handle_delete_sonarr_episode_file_event() { + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Delete, + None, + None, + None, + SonarrEvent::DeleteEpisodeFile(None), + Some("/1"), + None, + ) + .await; + app_arc.lock().await.data.sonarr_data.season_details_modal = + Some(SeasonDetailsModal::default()); + app_arc + .lock() + .await + .data + .sonarr_data + .season_details_modal + .as_mut() + .unwrap() + .episodes + .set_items(vec![episode()]); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + assert!(network + .handle_sonarr_event(SonarrEvent::DeleteEpisodeFile(None)) + .await + .is_ok()); + + async_server.assert_async().await; + } + + #[tokio::test] + async fn test_handle_delete_sonarr_episode_file_event_uses_provided_id() { + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Delete, + None, + None, + None, + SonarrEvent::DeleteEpisodeFile(None), + Some("/1"), + None, + ) + .await; + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + assert!(network + .handle_sonarr_event(SonarrEvent::DeleteEpisodeFile(Some(1))) + .await + .is_ok()); + + async_server.assert_async().await; + } + + #[tokio::test] + #[should_panic(expected = "Season details have not been loaded")] + async fn test_handle_delete_sonarr_episode_file_event_empty_season_details_modal_panics() { + let (_async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Delete, + None, + None, + None, + SonarrEvent::DeleteEpisodeFile(None), + Some("/1"), + None, + ) + .await; + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + network + .handle_sonarr_event(SonarrEvent::DeleteEpisodeFile(None)) + .await + .unwrap(); + } + #[tokio::test] async fn test_handle_delete_sonarr_download_event() { let (async_server, app_arc, _server) = mock_servarr_api( From 1dd4cd74c3ea5f1a6b0637fa0db987993c298b1c Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Mon, 25 Nov 2024 14:46:33 -0700 Subject: [PATCH 107/119] feat(cli): Support for deleting an episode file from disk --- src/cli/sonarr/delete_command_handler.rs | 12 +++++++ .../sonarr/delete_command_handler_tests.rs | 33 +++++++++++++++++++ 2 files changed, 45 insertions(+) diff --git a/src/cli/sonarr/delete_command_handler.rs b/src/cli/sonarr/delete_command_handler.rs index 15d052f..1bf03b2 100644 --- a/src/cli/sonarr/delete_command_handler.rs +++ b/src/cli/sonarr/delete_command_handler.rs @@ -33,6 +33,11 @@ pub enum SonarrDeleteCommand { #[arg(long, help = "The ID of the download to delete", required = true)] download_id: i64, }, + #[command(about = "Delete the specified episode file from disk")] + EpisodeFile { + #[arg(long, help = "The ID of the episode file to delete", required = true)] + episode_file_id: i64, + }, #[command(about = "Delete the indexer with the given ID")] Indexer { #[arg(long, help = "The ID of the indexer to delete", required = true)] @@ -100,6 +105,13 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, SonarrDeleteCommand> for SonarrDeleteComm .await?; serde_json::to_string_pretty(&resp)? } + SonarrDeleteCommand::EpisodeFile { episode_file_id } => { + let resp = self + .network + .handle_network_event(SonarrEvent::DeleteEpisodeFile(Some(episode_file_id)).into()) + .await?; + serde_json::to_string_pretty(&resp)? + } SonarrDeleteCommand::Indexer { indexer_id } => { let resp = self .network diff --git a/src/cli/sonarr/delete_command_handler_tests.rs b/src/cli/sonarr/delete_command_handler_tests.rs index 35d7d4d..9813e3a 100644 --- a/src/cli/sonarr/delete_command_handler_tests.rs +++ b/src/cli/sonarr/delete_command_handler_tests.rs @@ -93,6 +93,39 @@ mod tests { } } + #[test] + fn test_delete_episode_file_requires_arguments() { + let result = + Cli::command().try_get_matches_from(["managarr", "sonarr", "delete", "episode-file"]); + + assert!(result.is_err()); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_delete_episode_file_success() { + let expected_args = SonarrDeleteCommand::EpisodeFile { episode_file_id: 1 }; + + let result = Cli::try_parse_from([ + "managarr", + "sonarr", + "delete", + "episode-file", + "--episode-file-id", + "1", + ]); + + assert!(result.is_ok()); + + if let Some(Command::Sonarr(SonarrCommand::Delete(delete_command))) = result.unwrap().command + { + assert_eq!(delete_command, expected_args); + } + } + #[test] fn test_delete_indexer_requires_arguments() { let result = Cli::command().try_get_matches_from(["managarr", "sonarr", "delete", "indexer"]); From d43862a3a72cd940d9bb93c164385a6dffc9ba9d Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Mon, 25 Nov 2024 15:17:13 -0700 Subject: [PATCH 108/119] feat(network): Support for editing a sonarr indexer --- src/cli/radarr/edit_command_handler.rs | 4 +- src/cli/radarr/edit_command_handler_tests.rs | 5 +- .../indexers/edit_indexer_handler_tests.rs | 14 +- .../indexers/indexers_handler_tests.rs | 2 +- src/models/radarr_models.rs | 16 - src/models/servarr_data/modals.rs | 12 + src/models/servarr_data/radarr/modals.rs | 13 +- .../servarr_data/radarr/modals_tests.rs | 2 - src/models/servarr_data/radarr/radarr_data.rs | 4 +- src/models/servarr_data/sonarr/modals.rs | 81 ++- .../servarr_data/sonarr/modals_tests.rs | 57 ++ src/models/servarr_data/sonarr/sonarr_data.rs | 4 +- .../servarr_data/sonarr/sonarr_data_tests.rs | 1 + src/models/servarr_models.rs | 16 + src/network/radarr_network.rs | 26 +- src/network/radarr_network_tests.rs | 13 +- src/network/sonarr_network.rs | 270 ++++++++- src/network/sonarr_network_tests.rs | 572 +++++++++++++++++- 18 files changed, 1041 insertions(+), 71 deletions(-) diff --git a/src/cli/radarr/edit_command_handler.rs b/src/cli/radarr/edit_command_handler.rs index ca731d3..0d206d8 100644 --- a/src/cli/radarr/edit_command_handler.rs +++ b/src/cli/radarr/edit_command_handler.rs @@ -9,9 +9,9 @@ use crate::{ cli::{mutex_flags_or_default, mutex_flags_or_option, CliCommandHandler, Command}, models::{ radarr_models::{ - EditCollectionParams, EditIndexerParams, EditMovieParams, IndexerSettings, - MinimumAvailability, RadarrSerdeable, + EditCollectionParams, EditMovieParams, IndexerSettings, MinimumAvailability, RadarrSerdeable, }, + servarr_models::EditIndexerParams, Serdeable, }, network::{radarr_network::RadarrEvent, NetworkTrait}, diff --git a/src/cli/radarr/edit_command_handler_tests.rs b/src/cli/radarr/edit_command_handler_tests.rs index a960954..5fc2a2a 100644 --- a/src/cli/radarr/edit_command_handler_tests.rs +++ b/src/cli/radarr/edit_command_handler_tests.rs @@ -810,9 +810,10 @@ mod tests { }, models::{ radarr_models::{ - EditCollectionParams, EditIndexerParams, EditMovieParams, IndexerSettings, - MinimumAvailability, RadarrSerdeable, + EditCollectionParams, EditMovieParams, IndexerSettings, MinimumAvailability, + RadarrSerdeable, }, + servarr_models::EditIndexerParams, Serdeable, }, network::{radarr_network::RadarrEvent, MockNetworkTrait, NetworkEvent}, diff --git a/src/handlers/radarr_handlers/indexers/edit_indexer_handler_tests.rs b/src/handlers/radarr_handlers/indexers/edit_indexer_handler_tests.rs index 057b5ab..8c33809 100644 --- a/src/handlers/radarr_handlers/indexers/edit_indexer_handler_tests.rs +++ b/src/handlers/radarr_handlers/indexers/edit_indexer_handler_tests.rs @@ -5,7 +5,7 @@ mod tests { use crate::event::Key; use crate::handlers::radarr_handlers::indexers::edit_indexer_handler::EditIndexerHandler; use crate::handlers::KeyEventHandler; - use crate::models::servarr_data::radarr::modals::EditIndexerModal; + use crate::models::servarr_data::modals::EditIndexerModal; use crate::models::servarr_data::radarr::radarr_data::{ActiveRadarrBlock, EDIT_INDEXER_BLOCKS}; use strum::IntoEnumIterator; @@ -14,7 +14,7 @@ mod tests { use pretty_assertions::assert_eq; use rstest::rstest; - use crate::models::servarr_data::radarr::modals::EditIndexerModal; + use crate::models::servarr_data::modals::EditIndexerModal; use crate::models::servarr_data::radarr::radarr_data::EDIT_INDEXER_TORRENT_SELECTION_BLOCKS; use crate::models::BlockSelectionState; @@ -69,7 +69,7 @@ mod tests { use std::sync::atomic::Ordering; use crate::app::App; - use crate::models::servarr_data::radarr::modals::EditIndexerModal; + use crate::models::servarr_data::modals::EditIndexerModal; use pretty_assertions::assert_eq; use super::*; @@ -334,7 +334,7 @@ mod tests { use std::sync::atomic::Ordering; use crate::app::App; - use crate::models::servarr_data::radarr::modals::EditIndexerModal; + use crate::models::servarr_data::modals::EditIndexerModal; use crate::models::servarr_data::radarr::radarr_data::{ EDIT_INDEXER_NZB_SELECTION_BLOCKS, EDIT_INDEXER_TORRENT_SELECTION_BLOCKS, }; @@ -759,7 +759,7 @@ mod tests { use rstest::rstest; use crate::app::App; - use crate::models::servarr_data::radarr::modals::EditIndexerModal; + use crate::models::servarr_data::modals::EditIndexerModal; use crate::models::{ servarr_data::radarr::radarr_data::EDIT_INDEXER_TORRENT_SELECTION_BLOCKS, BlockSelectionState, }; @@ -1224,7 +1224,7 @@ mod tests { use super::*; use crate::app::App; use crate::event::Key; - use crate::models::servarr_data::radarr::modals::EditIndexerModal; + use crate::models::servarr_data::modals::EditIndexerModal; use pretty_assertions::assert_eq; use rstest::rstest; @@ -1281,7 +1281,7 @@ mod tests { mod test_handle_key_char { use crate::app::App; - use crate::models::servarr_data::radarr::modals::EditIndexerModal; + use crate::models::servarr_data::modals::EditIndexerModal; use crate::models::servarr_data::radarr::radarr_data::EDIT_INDEXER_TORRENT_SELECTION_BLOCKS; use crate::models::BlockSelectionState; use crate::network::radarr_network::RadarrEvent; diff --git a/src/handlers/radarr_handlers/indexers/indexers_handler_tests.rs b/src/handlers/radarr_handlers/indexers/indexers_handler_tests.rs index 50d646c..5fd3b1d 100644 --- a/src/handlers/radarr_handlers/indexers/indexers_handler_tests.rs +++ b/src/handlers/radarr_handlers/indexers/indexers_handler_tests.rs @@ -237,7 +237,7 @@ mod tests { } mod test_handle_submit { - use crate::models::servarr_data::radarr::modals::EditIndexerModal; + use crate::models::servarr_data::modals::EditIndexerModal; use crate::models::servarr_data::radarr::radarr_data::{ RadarrData, EDIT_INDEXER_NZB_SELECTION_BLOCKS, EDIT_INDEXER_TORRENT_SELECTION_BLOCKS, }; diff --git a/src/models/radarr_models.rs b/src/models/radarr_models.rs index 6049e5f..a99974e 100644 --- a/src/models/radarr_models.rs +++ b/src/models/radarr_models.rs @@ -179,22 +179,6 @@ pub struct EditCollectionParams { pub search_on_add: Option, } -#[derive(Default, Clone, Serialize, Debug, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct EditIndexerParams { - pub indexer_id: i64, - pub name: Option, - pub enable_rss: Option, - pub enable_automatic_search: Option, - pub enable_interactive_search: Option, - pub url: Option, - pub api_key: Option, - pub seed_ratio: Option, - pub tags: Option>, - pub priority: Option, - pub clear_tags: bool, -} - #[derive(Default, Clone, Serialize, Debug, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct EditMovieParams { diff --git a/src/models/servarr_data/modals.rs b/src/models/servarr_data/modals.rs index 84d8922..0105249 100644 --- a/src/models/servarr_data/modals.rs +++ b/src/models/servarr_data/modals.rs @@ -1,5 +1,17 @@ use crate::models::HorizontallyScrollableText; +#[derive(Default, Debug, PartialEq, Eq)] +pub struct EditIndexerModal { + pub name: HorizontallyScrollableText, + pub enable_rss: Option, + pub enable_automatic_search: Option, + pub enable_interactive_search: Option, + pub url: HorizontallyScrollableText, + pub api_key: HorizontallyScrollableText, + pub seed_ratio: HorizontallyScrollableText, + pub tags: HorizontallyScrollableText, +} + #[derive(Default, Clone, Eq, PartialEq, Debug)] pub struct IndexerTestResultModalItem { pub name: String, diff --git a/src/models/servarr_data/radarr/modals.rs b/src/models/servarr_data/radarr/modals.rs index 843d5ea..1b9c0d9 100644 --- a/src/models/servarr_data/radarr/modals.rs +++ b/src/models/servarr_data/radarr/modals.rs @@ -3,6 +3,7 @@ use strum::IntoEnumIterator; use crate::models::radarr_models::{ Collection, Credit, MinimumAvailability, Movie, MovieHistoryItem, MovieMonitor, RadarrRelease, }; +use crate::models::servarr_data::modals::EditIndexerModal; use crate::models::servarr_data::radarr::radarr_data::RadarrData; use crate::models::servarr_models::{Indexer, RootFolder}; use crate::models::stateful_list::StatefulList; @@ -25,18 +26,6 @@ pub struct MovieDetailsModal { pub movie_releases: StatefulTable, } -#[derive(Default, Debug, PartialEq, Eq)] -pub struct EditIndexerModal { - pub name: HorizontallyScrollableText, - pub enable_rss: Option, - pub enable_automatic_search: Option, - pub enable_interactive_search: Option, - pub url: HorizontallyScrollableText, - pub api_key: HorizontallyScrollableText, - pub seed_ratio: HorizontallyScrollableText, - pub tags: HorizontallyScrollableText, -} - impl From<&RadarrData<'_>> for EditIndexerModal { fn from(radarr_data: &RadarrData<'_>) -> EditIndexerModal { let mut edit_indexer_modal = EditIndexerModal::default(); diff --git a/src/models/servarr_data/radarr/modals_tests.rs b/src/models/servarr_data/radarr/modals_tests.rs index 96e36ad..e19d8a9 100644 --- a/src/models/servarr_data/radarr/modals_tests.rs +++ b/src/models/servarr_data/radarr/modals_tests.rs @@ -16,8 +16,6 @@ mod test { #[rstest] fn test_edit_indexer_modal_from_radarr_data(#[values(true, false)] seed_ratio_present: bool) { - use crate::models::servarr_models::{Indexer, IndexerField}; - let mut radarr_data = RadarrData { tags_map: BiMap::from_iter([(1, "usenet".to_owned()), (2, "test".to_owned())]), ..RadarrData::default() diff --git a/src/models/servarr_data/radarr/radarr_data.rs b/src/models/servarr_data/radarr/radarr_data.rs index 207c2e2..dcae501 100644 --- a/src/models/servarr_data/radarr/radarr_data.rs +++ b/src/models/servarr_data/radarr/radarr_data.rs @@ -9,9 +9,9 @@ use crate::models::radarr_models::{ AddMovieSearchResult, BlocklistItem, Collection, CollectionMovie, DownloadRecord, IndexerSettings, Movie, RadarrTask, }; -use crate::models::servarr_data::modals::IndexerTestResultModalItem; +use crate::models::servarr_data::modals::{EditIndexerModal, IndexerTestResultModalItem}; use crate::models::servarr_data::radarr::modals::{ - AddMovieModal, EditCollectionModal, EditIndexerModal, EditMovieModal, MovieDetailsModal, + AddMovieModal, EditCollectionModal, EditMovieModal, MovieDetailsModal, }; use crate::models::servarr_models::{DiskSpace, Indexer, QueueEvent, RootFolder}; use crate::models::stateful_list::StatefulList; diff --git a/src/models/servarr_data/sonarr/modals.rs b/src/models/servarr_data/sonarr/modals.rs index c5a128d..208c9a0 100644 --- a/src/models/servarr_data/sonarr/modals.rs +++ b/src/models/servarr_data/sonarr/modals.rs @@ -1,7 +1,8 @@ use strum::IntoEnumIterator; use crate::models::{ - servarr_models::RootFolder, + servarr_data::modals::EditIndexerModal, + servarr_models::{Indexer, RootFolder}, sonarr_models::{Episode, SeriesMonitor, SeriesType, SonarrHistoryItem, SonarrRelease}, stateful_list::StatefulList, stateful_table::StatefulTable, @@ -63,6 +64,84 @@ impl From<&SonarrData> for AddSeriesModal { } } +impl From<&SonarrData> for EditIndexerModal { + fn from(sonarr_data: &SonarrData) -> EditIndexerModal { + let mut edit_indexer_modal = EditIndexerModal::default(); + let Indexer { + name, + enable_rss, + enable_automatic_search, + enable_interactive_search, + tags, + fields, + .. + } = sonarr_data.indexers.current_selection(); + let seed_ratio_field_option = fields + .as_ref() + .unwrap() + .iter() + .find(|field| field.name.as_ref().unwrap() == "seedCriteria.seedRatio"); + let seed_ratio_value_option = if let Some(seed_ratio_field) = seed_ratio_field_option { + seed_ratio_field.value.clone() + } else { + None + }; + + edit_indexer_modal.name = name.clone().unwrap().into(); + edit_indexer_modal.enable_rss = Some(*enable_rss); + edit_indexer_modal.enable_automatic_search = Some(*enable_automatic_search); + edit_indexer_modal.enable_interactive_search = Some(*enable_interactive_search); + edit_indexer_modal.url = fields + .as_ref() + .unwrap() + .iter() + .find(|field| field.name.as_ref().unwrap() == "baseUrl") + .unwrap() + .value + .clone() + .unwrap() + .as_str() + .unwrap() + .into(); + edit_indexer_modal.api_key = fields + .as_ref() + .unwrap() + .iter() + .find(|field| field.name.as_ref().unwrap() == "apiKey") + .unwrap() + .value + .clone() + .unwrap() + .as_str() + .unwrap() + .into(); + + if seed_ratio_value_option.is_some() { + edit_indexer_modal.seed_ratio = seed_ratio_value_option + .unwrap() + .as_f64() + .unwrap() + .to_string() + .into(); + } + + edit_indexer_modal.tags = tags + .iter() + .map(|tag_id| { + sonarr_data + .tags_map + .get_by_left(&tag_id.as_i64().unwrap()) + .unwrap() + .clone() + }) + .collect::>() + .join(", ") + .into(); + + edit_indexer_modal + } +} + #[derive(Default)] pub struct EpisodeDetailsModal { pub episode_details: ScrollableText, diff --git a/src/models/servarr_data/sonarr/modals_tests.rs b/src/models/servarr_data/sonarr/modals_tests.rs index fc25384..0dfd63c 100644 --- a/src/models/servarr_data/sonarr/modals_tests.rs +++ b/src/models/servarr_data/sonarr/modals_tests.rs @@ -1,13 +1,19 @@ #[cfg(test)] mod tests { use bimap::BiMap; + use pretty_assertions::{assert_eq, assert_str_eq}; + use rstest::rstest; use strum::IntoEnumIterator; + use crate::models::servarr_models::{Indexer, IndexerField}; use crate::models::{ servarr_data::sonarr::{modals::AddSeriesModal, sonarr_data::SonarrData}, servarr_models::RootFolder, sonarr_models::{SeriesMonitor, SeriesType}, }; + use serde_json::{Number, Value}; + + use crate::models::servarr_data::modals::EditIndexerModal; #[test] fn test_add_series_modal_from_sonarr_data() { @@ -55,4 +61,55 @@ mod tests { assert!(add_series_modal.tags.text.is_empty()); assert!(add_series_modal.use_season_folder); } + + #[rstest] + fn test_edit_indexer_modal_from_sonarr_data(#[values(true, false)] seed_ratio_present: bool) { + let mut sonarr_data = SonarrData { + tags_map: BiMap::from_iter([(1, "usenet".to_owned()), (2, "test".to_owned())]), + ..SonarrData::default() + }; + let mut fields = vec![ + IndexerField { + name: Some("baseUrl".to_owned()), + value: Some(Value::String("https://test.com".to_owned())), + }, + IndexerField { + name: Some("apiKey".to_owned()), + value: Some(Value::String("1234".to_owned())), + }, + ]; + + if seed_ratio_present { + fields.push(IndexerField { + name: Some("seedCriteria.seedRatio".to_owned()), + value: Some(Value::from(1.2f64)), + }); + } + + let indexer = Indexer { + name: Some("Test".to_owned()), + enable_rss: true, + enable_automatic_search: true, + enable_interactive_search: true, + tags: vec![Number::from(1), Number::from(2)], + fields: Some(fields), + ..Indexer::default() + }; + sonarr_data.indexers.set_items(vec![indexer]); + + let edit_indexer_modal = EditIndexerModal::from(&sonarr_data); + + assert_str_eq!(edit_indexer_modal.name.text, "Test"); + assert_eq!(edit_indexer_modal.enable_rss, Some(true)); + assert_eq!(edit_indexer_modal.enable_automatic_search, Some(true)); + assert_eq!(edit_indexer_modal.enable_interactive_search, Some(true)); + assert_str_eq!(edit_indexer_modal.url.text, "https://test.com"); + assert_str_eq!(edit_indexer_modal.api_key.text, "1234"); + + if seed_ratio_present { + assert_str_eq!(edit_indexer_modal.seed_ratio.text, "1.2"); + } else { + assert!(edit_indexer_modal.seed_ratio.text.is_empty()); + } + } } diff --git a/src/models/servarr_data/sonarr/sonarr_data.rs b/src/models/servarr_data/sonarr/sonarr_data.rs index bd2962f..76fbd78 100644 --- a/src/models/servarr_data/sonarr/sonarr_data.rs +++ b/src/models/servarr_data/sonarr/sonarr_data.rs @@ -3,7 +3,7 @@ use chrono::{DateTime, Utc}; use strum::EnumIter; use crate::models::{ - servarr_data::modals::IndexerTestResultModalItem, + servarr_data::modals::{EditIndexerModal, IndexerTestResultModalItem}, servarr_models::{DiskSpace, Indexer, QueueEvent, RootFolder}, sonarr_models::{ AddSeriesSearchResult, BlocklistItem, DownloadRecord, IndexerSettings, Season, Series, @@ -29,6 +29,7 @@ pub struct SonarrData { pub delete_series_files: bool, pub downloads: StatefulTable, pub disk_space_vec: Vec, + pub edit_indexer_modal: Option, pub edit_root_folder: Option, pub history: StatefulTable, pub indexers: StatefulTable, @@ -69,6 +70,7 @@ impl Default for SonarrData { downloads: StatefulTable::default(), delete_series_files: false, disk_space_vec: Vec::new(), + edit_indexer_modal: None, edit_root_folder: None, history: StatefulTable::default(), indexers: StatefulTable::default(), diff --git a/src/models/servarr_data/sonarr/sonarr_data_tests.rs b/src/models/servarr_data/sonarr/sonarr_data_tests.rs index a698b8f..30c7ee6 100644 --- a/src/models/servarr_data/sonarr/sonarr_data_tests.rs +++ b/src/models/servarr_data/sonarr/sonarr_data_tests.rs @@ -56,6 +56,7 @@ mod tests { assert!(!sonarr_data.delete_series_files); assert!(sonarr_data.downloads.is_empty()); assert!(sonarr_data.disk_space_vec.is_empty()); + assert!(sonarr_data.edit_indexer_modal.is_none()); assert!(sonarr_data.edit_root_folder.is_none()); assert!(sonarr_data.history.is_empty()); assert!(sonarr_data.indexers.is_empty()); diff --git a/src/models/servarr_models.rs b/src/models/servarr_models.rs index 02134e0..4f1810b 100644 --- a/src/models/servarr_models.rs +++ b/src/models/servarr_models.rs @@ -89,6 +89,22 @@ pub struct DiskSpace { pub total_space: i64, } +#[derive(Default, Clone, Serialize, Debug, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct EditIndexerParams { + pub indexer_id: i64, + pub name: Option, + pub enable_rss: Option, + pub enable_automatic_search: Option, + pub enable_interactive_search: Option, + pub url: Option, + pub api_key: Option, + pub seed_ratio: Option, + pub tags: Option>, + pub priority: Option, + pub clear_tags: bool, +} + #[derive(Default, Deserialize, Serialize, Debug, Clone, Eq, PartialEq)] #[serde(rename_all = "camelCase")] pub struct HostConfig { diff --git a/src/network/radarr_network.rs b/src/network/radarr_network.rs index efb6823..0c5a8c3 100644 --- a/src/network/radarr_network.rs +++ b/src/network/radarr_network.rs @@ -9,18 +9,18 @@ use urlencoding::encode; use crate::models::radarr_models::{ AddMovieBody, AddMovieOptions, AddMovieSearchResult, BlocklistResponse, Collection, CollectionMovie, Credit, CreditType, DeleteMovieParams, DownloadRecord, DownloadsResponse, - EditCollectionParams, EditIndexerParams, EditMovieParams, IndexerSettings, IndexerTestResult, - Movie, MovieCommandBody, MovieHistoryItem, RadarrRelease, RadarrReleaseDownloadBody, - RadarrSerdeable, RadarrTask, RadarrTaskName, SystemStatus, + EditCollectionParams, EditMovieParams, IndexerSettings, IndexerTestResult, Movie, + MovieCommandBody, MovieHistoryItem, RadarrRelease, RadarrReleaseDownloadBody, RadarrSerdeable, + RadarrTask, RadarrTaskName, SystemStatus, }; -use crate::models::servarr_data::modals::IndexerTestResultModalItem; +use crate::models::servarr_data::modals::{EditIndexerModal, IndexerTestResultModalItem}; use crate::models::servarr_data::radarr::modals::{ - AddMovieModal, EditCollectionModal, EditIndexerModal, EditMovieModal, MovieDetailsModal, + AddMovieModal, EditCollectionModal, EditMovieModal, MovieDetailsModal, }; use crate::models::servarr_data::radarr::radarr_data::ActiveRadarrBlock; use crate::models::servarr_models::{ - AddRootFolderBody, CommandBody, DiskSpace, HostConfig, Indexer, LogResponse, QualityProfile, - QueueEvent, RootFolder, SecurityConfig, Tag, Update, + AddRootFolderBody, CommandBody, DiskSpace, EditIndexerParams, HostConfig, Indexer, LogResponse, + QualityProfile, QueueEvent, RootFolder, SecurityConfig, Tag, Update, }; use crate::models::stateful_table::StatefulTable; use crate::models::{HorizontallyScrollableText, Route, Scrollable, ScrollableText}; @@ -188,9 +188,10 @@ impl<'a, 'b> Network<'a, 'b> { .edit_collection(params) .await .map(RadarrSerdeable::from), - RadarrEvent::EditIndexer(params) => { - self.edit_indexer(params).await.map(RadarrSerdeable::from) - } + RadarrEvent::EditIndexer(params) => self + .edit_radarr_indexer(params) + .await + .map(RadarrSerdeable::from), RadarrEvent::EditMovie(params) => self.edit_movie(params).await.map(RadarrSerdeable::from), RadarrEvent::GetAllIndexerSettings => self .get_all_radarr_indexer_settings() @@ -882,7 +883,10 @@ impl<'a, 'b> Network<'a, 'b> { .await } - async fn edit_indexer(&mut self, edit_indexer_params: Option) -> Result<()> { + async fn edit_radarr_indexer( + &mut self, + edit_indexer_params: Option, + ) -> Result<()> { let detail_event = RadarrEvent::GetIndexers; let event = RadarrEvent::EditIndexer(None); let id = if let Some(ref params) = edit_indexer_params { diff --git a/src/network/radarr_network_tests.rs b/src/network/radarr_network_tests.rs index fc71755..c75ffd3 100644 --- a/src/network/radarr_network_tests.rs +++ b/src/network/radarr_network_tests.rs @@ -4053,7 +4053,7 @@ mod test { } #[tokio::test] - async fn test_handle_edit_indexer_event() { + async fn test_handle_edit_radarr_indexer_event() { let indexer_details_json = json!({ "enableRss": true, "enableAutomaticSearch": true, @@ -4153,7 +4153,7 @@ mod test { } #[tokio::test] - async fn test_handle_edit_indexer_event_does_not_add_seed_ratio_when_seed_ratio_field_is_none_in_details( + async fn test_handle_edit_radarr_indexer_event_does_not_add_seed_ratio_when_seed_ratio_field_is_none_in_details( ) { let indexer_details_json = json!({ "enableRss": true, @@ -4255,7 +4255,7 @@ mod test { } #[tokio::test] - async fn test_handle_edit_indexer_event_populates_the_seed_ratio_value_when_seed_ratio_field_is_present_in_details( + async fn test_handle_edit_radarr_indexer_event_populates_the_seed_ratio_value_when_seed_ratio_field_is_present_in_details( ) { let indexer_details_json = json!({ "enableRss": true, @@ -4371,7 +4371,7 @@ mod test { } #[tokio::test] - async fn test_handle_edit_indexer_event_uses_provided_parameters() { + async fn test_handle_edit_radarr_indexer_event_uses_provided_parameters() { let indexer_details_json = json!({ "enableRss": true, "enableAutomaticSearch": true, @@ -4464,7 +4464,8 @@ mod test { } #[tokio::test] - async fn test_handle_edit_indexer_event_uses_provided_parameters_defaults_to_previous_values() { + async fn test_handle_edit_radarr_indexer_event_uses_provided_parameters_defaults_to_previous_values( + ) { let indexer_details_json = json!({ "enableRss": true, "enableAutomaticSearch": true, @@ -4525,7 +4526,7 @@ mod test { } #[tokio::test] - async fn test_handle_edit_indexer_event_uses_provided_parameters_clears_tags_when_clear_tags_is_true( + async fn test_handle_edit_radarr_indexer_event_uses_provided_parameters_clears_tags_when_clear_tags_is_true( ) { let indexer_details_json = json!({ "enableRss": true, diff --git a/src/network/sonarr_network.rs b/src/network/sonarr_network.rs index 27ba7fc..20cbecc 100644 --- a/src/network/sonarr_network.rs +++ b/src/network/sonarr_network.rs @@ -8,15 +8,15 @@ use crate::{ models::{ radarr_models::IndexerTestResult, servarr_data::{ - modals::IndexerTestResultModalItem, + modals::{EditIndexerModal, IndexerTestResultModalItem}, sonarr::{ modals::{AddSeriesModal, EpisodeDetailsModal, SeasonDetailsModal}, sonarr_data::ActiveSonarrBlock, }, }, servarr_models::{ - AddRootFolderBody, CommandBody, DiskSpace, HostConfig, Indexer, Language, LogResponse, - QualityProfile, QueueEvent, RootFolder, SecurityConfig, Tag, Update, + AddRootFolderBody, CommandBody, DiskSpace, EditIndexerParams, HostConfig, Indexer, Language, + LogResponse, QualityProfile, QueueEvent, RootFolder, SecurityConfig, Tag, Update, }, sonarr_models::{ AddSeriesBody, AddSeriesOptions, AddSeriesSearchResult, BlocklistResponse, @@ -51,6 +51,7 @@ pub enum SonarrEvent { DeleteTag(i64), DownloadRelease(SonarrReleaseDownloadBody), EditAllIndexerSettings(Option), + EditIndexer(Option), GetAllIndexerSettings, GetBlocklist, GetDownloads, @@ -106,7 +107,9 @@ impl NetworkResource for SonarrEvent { SonarrEvent::GetEpisodes(_) | SonarrEvent::GetEpisodeDetails(_) => "/episode", SonarrEvent::GetHistory(_) | SonarrEvent::GetEpisodeHistory(_) => "/history", SonarrEvent::GetHostConfig | SonarrEvent::GetSecurityConfig => "/config/host", - SonarrEvent::GetIndexers | SonarrEvent::DeleteIndexer(_) => "/indexer", + SonarrEvent::GetIndexers | SonarrEvent::DeleteIndexer(_) | SonarrEvent::EditIndexer(_) => { + "/indexer" + } SonarrEvent::GetLanguageProfiles => "/languageprofile", SonarrEvent::GetLogs(_) => "/log", SonarrEvent::GetDiskSpace => "/diskspace", @@ -204,6 +207,10 @@ impl<'a, 'b> Network<'a, 'b> { .edit_all_sonarr_indexer_settings(params) .await .map(SonarrSerdeable::from), + SonarrEvent::EditIndexer(params) => self + .edit_sonarr_indexer(params) + .await + .map(SonarrSerdeable::from), SonarrEvent::GetBlocklist => self.get_sonarr_blocklist().await.map(SonarrSerdeable::from), SonarrEvent::GetDownloads => self.get_sonarr_downloads().await.map(SonarrSerdeable::from), SonarrEvent::GetEpisodes(series_id) => self @@ -800,6 +807,261 @@ impl<'a, 'b> Network<'a, 'b> { resp } + async fn edit_sonarr_indexer( + &mut self, + edit_indexer_params: Option, + ) -> Result<()> { + let detail_event = SonarrEvent::GetIndexers; + let event = SonarrEvent::EditIndexer(None); + let id = if let Some(ref params) = edit_indexer_params { + params.indexer_id + } else { + self + .app + .lock() + .await + .data + .sonarr_data + .indexers + .current_selection() + .id + }; + info!("Updating Sonarr indexer with ID: {id}"); + + info!("Fetching indexer details for indexer with ID: {id}"); + + let request_props = self + .request_props_from( + detail_event, + RequestMethod::Get, + None::<()>, + Some(format!("/{id}")), + None, + ) + .await; + + let mut response = String::new(); + + self + .handle_request::<(), Value>(request_props, |detailed_indexer_body, _| { + response = detailed_indexer_body.to_string() + }) + .await?; + + info!("Constructing edit indexer body"); + + let mut detailed_indexer_body: Value = serde_json::from_str(&response).unwrap(); + let priority = detailed_indexer_body["priority"] + .as_i64() + .expect("Unable to deserialize 'priority'"); + + let ( + name, + enable_rss, + enable_automatic_search, + enable_interactive_search, + url, + api_key, + seed_ratio, + tags, + priority, + ) = if let Some(params) = edit_indexer_params { + let seed_ratio_field_option = detailed_indexer_body["fields"] + .as_array() + .unwrap() + .iter() + .find(|field| field["name"] == "seedCriteria.seedRatio"); + let name = params.name.unwrap_or( + detailed_indexer_body["name"] + .as_str() + .expect("Unable to deserialize 'name'") + .to_owned(), + ); + let enable_rss = params.enable_rss.unwrap_or( + detailed_indexer_body["enableRss"] + .as_bool() + .expect("Unable to deserialize 'enableRss'"), + ); + let enable_automatic_search = params.enable_automatic_search.unwrap_or( + detailed_indexer_body["enableAutomaticSearch"] + .as_bool() + .expect("Unable to deserialize 'enableAutomaticSearch"), + ); + let enable_interactive_search = params.enable_interactive_search.unwrap_or( + detailed_indexer_body["enableInteractiveSearch"] + .as_bool() + .expect("Unable to deserialize 'enableInteractiveSearch'"), + ); + let url = params.url.unwrap_or( + detailed_indexer_body["fields"] + .as_array() + .expect("Unable to deserialize 'fields'") + .iter() + .find(|field| field["name"] == "baseUrl") + .expect("Field 'baseUrl' was not found in the 'fields' array") + .get("value") + .unwrap_or(&json!("")) + .as_str() + .expect("Unable to deserialize 'baseUrl value'") + .to_owned(), + ); + let api_key = params.api_key.unwrap_or( + detailed_indexer_body["fields"] + .as_array() + .expect("Unable to deserialize 'fields'") + .iter() + .find(|field| field["name"] == "apiKey") + .expect("Field 'apiKey' was not found in the 'fields' array") + .get("value") + .unwrap_or(&json!("")) + .as_str() + .expect("Unable to deserialize 'apiKey value'") + .to_owned(), + ); + let seed_ratio = params.seed_ratio.unwrap_or_else(|| { + if let Some(seed_ratio_field) = seed_ratio_field_option { + return seed_ratio_field + .get("value") + .unwrap_or(&json!("")) + .as_str() + .expect("Unable to deserialize 'seedCriteria.seedRatio value'") + .to_owned(); + } + + String::new() + }); + let tags = if params.clear_tags { + vec![] + } else { + params.tags.unwrap_or( + detailed_indexer_body["tags"] + .as_array() + .expect("Unable to deserialize 'tags'") + .iter() + .map(|item| item.as_i64().expect("Unable to deserialize tag ID")) + .collect(), + ) + }; + let priority = params.priority.unwrap_or(priority); + + ( + name, + enable_rss, + enable_automatic_search, + enable_interactive_search, + url, + api_key, + seed_ratio, + tags, + priority, + ) + } else { + let tags = self + .app + .lock() + .await + .data + .sonarr_data + .edit_indexer_modal + .as_ref() + .unwrap() + .tags + .text + .clone(); + let tag_ids_vec = self.extract_and_add_sonarr_tag_ids_vec(tags).await; + let mut app = self.app.lock().await; + + let params = { + let EditIndexerModal { + name, + enable_rss, + enable_automatic_search, + enable_interactive_search, + url, + api_key, + seed_ratio, + .. + } = app.data.sonarr_data.edit_indexer_modal.as_ref().unwrap(); + + ( + name.text.clone(), + enable_rss.unwrap_or_default(), + enable_automatic_search.unwrap_or_default(), + enable_interactive_search.unwrap_or_default(), + url.text.clone(), + api_key.text.clone(), + seed_ratio.text.clone(), + tag_ids_vec, + priority, + ) + }; + + app.data.sonarr_data.edit_indexer_modal = None; + + params + }; + + *detailed_indexer_body.get_mut("name").unwrap() = json!(name); + *detailed_indexer_body.get_mut("priority").unwrap() = json!(priority); + *detailed_indexer_body.get_mut("enableRss").unwrap() = json!(enable_rss); + *detailed_indexer_body + .get_mut("enableAutomaticSearch") + .unwrap() = json!(enable_automatic_search); + *detailed_indexer_body + .get_mut("enableInteractiveSearch") + .unwrap() = json!(enable_interactive_search); + *detailed_indexer_body + .get_mut("fields") + .unwrap() + .as_array_mut() + .unwrap() + .iter_mut() + .find(|field| field["name"] == "baseUrl") + .unwrap() + .get_mut("value") + .unwrap() = json!(url); + *detailed_indexer_body + .get_mut("fields") + .unwrap() + .as_array_mut() + .unwrap() + .iter_mut() + .find(|field| field["name"] == "apiKey") + .unwrap() + .get_mut("value") + .unwrap() = json!(api_key); + *detailed_indexer_body.get_mut("tags").unwrap() = json!(tags); + let seed_ratio_field_option = detailed_indexer_body + .get_mut("fields") + .unwrap() + .as_array_mut() + .unwrap() + .iter_mut() + .find(|field| field["name"] == "seedCriteria.seedRatio"); + if let Some(seed_ratio_field) = seed_ratio_field_option { + seed_ratio_field + .as_object_mut() + .unwrap() + .insert("value".to_string(), json!(seed_ratio)); + } + + debug!("Edit indexer body: {detailed_indexer_body:?}"); + + let request_props = self + .request_props_from( + event, + RequestMethod::Put, + Some(detailed_indexer_body), + Some(format!("/{id}")), + None, + ) + .await; + + self + .handle_request::(request_props, |_, _| ()) + .await + } + async fn get_all_sonarr_indexer_settings(&mut self) -> Result { info!("Fetching Sonarr indexer settings"); let event = SonarrEvent::GetAllIndexerSettings; diff --git a/src/network/sonarr_network_tests.rs b/src/network/sonarr_network_tests.rs index 4952610..16ad396 100644 --- a/src/network/sonarr_network_tests.rs +++ b/src/network/sonarr_network_tests.rs @@ -22,14 +22,14 @@ mod test { use crate::app::{App, ServarrConfig}; use crate::models::radarr_models::IndexerTestResult; - use crate::models::servarr_data::modals::IndexerTestResultModalItem; + use crate::models::servarr_data::modals::{EditIndexerModal, IndexerTestResultModalItem}; use crate::models::servarr_data::sonarr::modals::{ AddSeriesModal, EpisodeDetailsModal, SeasonDetailsModal, }; use crate::models::servarr_data::sonarr::sonarr_data::ActiveSonarrBlock; use crate::models::servarr_models::{ - DiskSpace, HostConfig, Indexer, IndexerField, Language, LogResponse, Quality, QualityProfile, - QualityWrapper, QueueEvent, RootFolder, SecurityConfig, Tag, Update, + DiskSpace, EditIndexerParams, HostConfig, Indexer, IndexerField, Language, LogResponse, + Quality, QualityProfile, QualityWrapper, QueueEvent, RootFolder, SecurityConfig, Tag, Update, }; use crate::models::sonarr_models::{ BlocklistItem, DeleteSeriesParams, DownloadRecord, DownloadsResponse, Episode, EpisodeFile, @@ -205,7 +205,12 @@ mod test { #[rstest] fn test_resource_indexer( - #[values(SonarrEvent::GetIndexers, SonarrEvent::DeleteIndexer(None))] event: SonarrEvent, + #[values( + SonarrEvent::GetIndexers, + SonarrEvent::DeleteIndexer(None), + SonarrEvent::EditIndexer(None) + )] + event: SonarrEvent, ) { assert_str_eq!(event.resource(), "/indexer"); } @@ -1126,6 +1131,565 @@ mod test { async_server.assert_async().await; } + #[tokio::test] + async fn test_handle_edit_sonarr_indexer_event() { + let indexer_details_json = json!({ + "enableRss": true, + "enableAutomaticSearch": true, + "enableInteractiveSearch": true, + "name": "Test Indexer", + "priority": 1, + "fields": [ + { + "name": "baseUrl", + "value": "https://test.com", + }, + { + "name": "apiKey", + "value": "", + }, + { + "name": "seedCriteria.seedRatio", + "value": "1.2", + }, + ], + "tags": [1], + "id": 1 + }); + let expected_indexer_edit_body_json = json!({ + "enableRss": false, + "enableAutomaticSearch": false, + "enableInteractiveSearch": false, + "name": "Test Update", + "priority": 1, + "fields": [ + { + "name": "baseUrl", + "value": "https://localhost:9696/1/", + }, + { + "name": "apiKey", + "value": "test1234", + }, + { + "name": "seedCriteria.seedRatio", + "value": "1.3", + }, + ], + "tags": [1, 2], + "id": 1 + }); + + let (async_details_server, app_arc, mut server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(indexer_details_json), + None, + SonarrEvent::GetIndexers, + Some("/1"), + None, + ) + .await; + let async_edit_server = server + .mock( + "PUT", + format!("/api/v3{}/1", SonarrEvent::EditIndexer(None).resource()).as_str(), + ) + .with_status(202) + .match_header("X-Api-Key", "test1234") + .match_body(Matcher::Json(expected_indexer_edit_body_json)) + .create_async() + .await; + { + let mut app = app_arc.lock().await; + app.data.sonarr_data.tags_map = + BiMap::from_iter([(1, "usenet".to_owned()), (2, "testing".to_owned())]); + let edit_indexer_modal = EditIndexerModal { + name: "Test Update".into(), + enable_rss: Some(false), + enable_automatic_search: Some(false), + enable_interactive_search: Some(false), + url: "https://localhost:9696/1/".into(), + api_key: "test1234".into(), + seed_ratio: "1.3".into(), + tags: "usenet, testing".into(), + }; + app.data.sonarr_data.edit_indexer_modal = Some(edit_indexer_modal); + app.data.sonarr_data.indexers.set_items(vec![indexer()]); + } + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + assert!(network + .handle_sonarr_event(SonarrEvent::EditIndexer(None)) + .await + .is_ok()); + + async_details_server.assert_async().await; + async_edit_server.assert_async().await; + + let app = app_arc.lock().await; + assert!(app.data.sonarr_data.edit_indexer_modal.is_none()); + } + + #[tokio::test] + async fn test_handle_edit_sonarr_indexer_event_does_not_add_seed_ratio_when_seed_ratio_field_is_none_in_details( + ) { + let indexer_details_json = json!({ + "enableRss": true, + "enableAutomaticSearch": true, + "enableInteractiveSearch": true, + "name": "Test Indexer", + "priority": 1, + "fields": [ + { + "name": "baseUrl", + "value": "https://test.com", + }, + { + "name": "apiKey", + "value": "", + }, + ], + "tags": [1], + "id": 1 + }); + let expected_indexer_edit_body_json = json!({ + "enableRss": false, + "enableAutomaticSearch": false, + "enableInteractiveSearch": false, + "name": "Test Update", + "priority": 1, + "fields": [ + { + "name": "baseUrl", + "value": "https://localhost:9696/1/", + }, + { + "name": "apiKey", + "value": "test1234", + }, + ], + "tags": [1, 2], + "id": 1 + }); + + let (async_details_server, app_arc, mut server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(indexer_details_json), + None, + SonarrEvent::GetIndexers, + Some("/1"), + None, + ) + .await; + let async_edit_server = server + .mock( + "PUT", + format!("/api/v3{}/1", SonarrEvent::EditIndexer(None).resource()).as_str(), + ) + .with_status(202) + .match_header("X-Api-Key", "test1234") + .match_body(Matcher::Json(expected_indexer_edit_body_json)) + .create_async() + .await; + { + let mut app = app_arc.lock().await; + app.data.sonarr_data.tags_map = + BiMap::from_iter([(1, "usenet".to_owned()), (2, "testing".to_owned())]); + let edit_indexer_modal = EditIndexerModal { + name: "Test Update".into(), + enable_rss: Some(false), + enable_automatic_search: Some(false), + enable_interactive_search: Some(false), + url: "https://localhost:9696/1/".into(), + api_key: "test1234".into(), + seed_ratio: "1.3".into(), + tags: "usenet, testing".into(), + }; + app.data.sonarr_data.edit_indexer_modal = Some(edit_indexer_modal); + let mut indexer = indexer(); + indexer.fields = Some( + indexer + .fields + .unwrap() + .into_iter() + .filter(|field| field.name != Some("seedCriteria.seedRatio".to_string())) + .collect(), + ); + app.data.sonarr_data.indexers.set_items(vec![indexer]); + } + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + assert!(network + .handle_sonarr_event(SonarrEvent::EditIndexer(None)) + .await + .is_ok()); + + async_details_server.assert_async().await; + async_edit_server.assert_async().await; + + let app = app_arc.lock().await; + assert!(app.data.sonarr_data.edit_indexer_modal.is_none()); + } + + #[tokio::test] + async fn test_handle_edit_sonarr_indexer_event_populates_the_seed_ratio_value_when_seed_ratio_field_is_present_in_details( + ) { + let indexer_details_json = json!({ + "enableRss": true, + "enableAutomaticSearch": true, + "enableInteractiveSearch": true, + "name": "Test Indexer", + "priority": 1, + "fields": [ + { + "name": "baseUrl", + "value": "https://test.com", + }, + { + "name": "apiKey", + "value": "", + }, + { + "name": "seedCriteria.seedRatio", + }, + ], + "tags": [1], + "id": 1 + }); + let expected_indexer_edit_body_json = json!({ + "enableRss": false, + "enableAutomaticSearch": false, + "enableInteractiveSearch": false, + "name": "Test Update", + "priority": 1, + "fields": [ + { + "name": "baseUrl", + "value": "https://localhost:9696/1/", + }, + { + "name": "apiKey", + "value": "test1234", + }, + { + "name": "seedCriteria.seedRatio", + "value": "1.3", + }, + ], + "tags": [1, 2], + "id": 1 + }); + + let (async_details_server, app_arc, mut server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(indexer_details_json), + None, + SonarrEvent::GetIndexers, + Some("/1"), + None, + ) + .await; + let async_edit_server = server + .mock( + "PUT", + format!("/api/v3{}/1", SonarrEvent::EditIndexer(None).resource()).as_str(), + ) + .with_status(202) + .match_header("X-Api-Key", "test1234") + .match_body(Matcher::Json(expected_indexer_edit_body_json)) + .create_async() + .await; + { + let mut app = app_arc.lock().await; + app.data.sonarr_data.tags_map = + BiMap::from_iter([(1, "usenet".to_owned()), (2, "testing".to_owned())]); + let edit_indexer_modal = EditIndexerModal { + name: "Test Update".into(), + enable_rss: Some(false), + enable_automatic_search: Some(false), + enable_interactive_search: Some(false), + url: "https://localhost:9696/1/".into(), + api_key: "test1234".into(), + seed_ratio: "1.3".into(), + tags: "usenet, testing".into(), + }; + app.data.sonarr_data.edit_indexer_modal = Some(edit_indexer_modal); + let mut indexer = indexer(); + indexer.fields = Some( + indexer + .fields + .unwrap() + .into_iter() + .map(|mut field| { + if field.name == Some("seedCriteria.seedRatio".to_string()) { + field.value = None; + field + } else { + field + } + }) + .collect(), + ); + app.data.sonarr_data.indexers.set_items(vec![indexer]); + } + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + assert!(network + .handle_sonarr_event(SonarrEvent::EditIndexer(None)) + .await + .is_ok()); + + async_details_server.assert_async().await; + async_edit_server.assert_async().await; + + let app = app_arc.lock().await; + assert!(app.data.sonarr_data.edit_indexer_modal.is_none()); + } + + #[tokio::test] + async fn test_handle_edit_sonarr_indexer_event_uses_provided_parameters() { + let indexer_details_json = json!({ + "enableRss": true, + "enableAutomaticSearch": true, + "enableInteractiveSearch": true, + "name": "Test Indexer", + "priority": 1, + "fields": [ + { + "name": "baseUrl", + "value": "https://test.com", + }, + { + "name": "apiKey", + "value": "", + }, + { + "name": "seedCriteria.seedRatio", + "value": "1.2", + }, + ], + "tags": [1], + "id": 1 + }); + let expected_indexer_edit_body_json = json!({ + "enableRss": false, + "enableAutomaticSearch": false, + "enableInteractiveSearch": false, + "name": "Test Update", + "priority": 25, + "fields": [ + { + "name": "baseUrl", + "value": "https://localhost:9696/1/", + }, + { + "name": "apiKey", + "value": "test1234", + }, + { + "name": "seedCriteria.seedRatio", + "value": "1.3", + }, + ], + "tags": [1, 2], + "id": 1 + }); + let edit_indexer_params = EditIndexerParams { + indexer_id: 1, + name: Some("Test Update".to_owned()), + enable_rss: Some(false), + enable_automatic_search: Some(false), + enable_interactive_search: Some(false), + url: Some("https://localhost:9696/1/".to_owned()), + api_key: Some("test1234".to_owned()), + seed_ratio: Some("1.3".to_owned()), + tags: Some(vec![1, 2]), + priority: Some(25), + ..EditIndexerParams::default() + }; + + let (async_details_server, app_arc, mut server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(indexer_details_json), + None, + SonarrEvent::GetIndexers, + Some("/1"), + None, + ) + .await; + let async_edit_server = server + .mock( + "PUT", + format!("/api/v3{}/1", SonarrEvent::EditIndexer(None).resource()).as_str(), + ) + .with_status(202) + .match_header("X-Api-Key", "test1234") + .match_body(Matcher::Json(expected_indexer_edit_body_json)) + .create_async() + .await; + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + assert!(network + .handle_sonarr_event(SonarrEvent::EditIndexer(Some(edit_indexer_params))) + .await + .is_ok()); + + async_details_server.assert_async().await; + async_edit_server.assert_async().await; + } + + #[tokio::test] + async fn test_handle_edit_sonarr_indexer_event_uses_provided_parameters_defaults_to_previous_values( + ) { + let indexer_details_json = json!({ + "enableRss": true, + "enableAutomaticSearch": true, + "enableInteractiveSearch": true, + "name": "Test Indexer", + "priority": 1, + "fields": [ + { + "name": "baseUrl", + "value": "https://test.com", + }, + { + "name": "apiKey", + "value": "", + }, + { + "name": "seedCriteria.seedRatio", + "value": "1.2", + }, + ], + "tags": [1], + "id": 1 + }); + let edit_indexer_params = EditIndexerParams { + indexer_id: 1, + ..EditIndexerParams::default() + }; + + let (async_details_server, app_arc, mut server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(indexer_details_json.clone()), + None, + SonarrEvent::GetIndexers, + Some("/1"), + None, + ) + .await; + let async_edit_server = server + .mock( + "PUT", + format!("/api/v3{}/1", SonarrEvent::EditIndexer(None).resource()).as_str(), + ) + .with_status(202) + .match_header("X-Api-Key", "test1234") + .match_body(Matcher::Json(indexer_details_json)) + .create_async() + .await; + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + assert!(network + .handle_sonarr_event(SonarrEvent::EditIndexer(Some(edit_indexer_params))) + .await + .is_ok()); + + async_details_server.assert_async().await; + async_edit_server.assert_async().await; + } + + #[tokio::test] + async fn test_handle_edit_sonarr_indexer_event_uses_provided_parameters_clears_tags_when_clear_tags_is_true( + ) { + let indexer_details_json = json!({ + "enableRss": true, + "enableAutomaticSearch": true, + "enableInteractiveSearch": true, + "name": "Test Indexer", + "priority": 1, + "fields": [ + { + "name": "baseUrl", + "value": "https://test.com", + }, + { + "name": "apiKey", + "value": "", + }, + { + "name": "seedCriteria.seedRatio", + "value": "1.2", + }, + ], + "tags": [1, 2], + "id": 1 + }); + let expected_edit_indexer_body = json!({ + "enableRss": true, + "enableAutomaticSearch": true, + "enableInteractiveSearch": true, + "name": "Test Indexer", + "priority": 1, + "fields": [ + { + "name": "baseUrl", + "value": "https://test.com", + }, + { + "name": "apiKey", + "value": "", + }, + { + "name": "seedCriteria.seedRatio", + "value": "1.2", + }, + ], + "tags": [], + "id": 1 + }); + let edit_indexer_params = EditIndexerParams { + indexer_id: 1, + clear_tags: true, + ..EditIndexerParams::default() + }; + + let (async_details_server, app_arc, mut server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(indexer_details_json), + None, + SonarrEvent::GetIndexers, + Some("/1"), + None, + ) + .await; + let async_edit_server = server + .mock( + "PUT", + format!("/api/v3{}/1", SonarrEvent::EditIndexer(None).resource()).as_str(), + ) + .with_status(202) + .match_header("X-Api-Key", "test1234") + .match_body(Matcher::Json(expected_edit_indexer_body)) + .create_async() + .await; + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + assert!(network + .handle_sonarr_event(SonarrEvent::EditIndexer(Some(edit_indexer_params))) + .await + .is_ok()); + + async_details_server.assert_async().await; + async_edit_server.assert_async().await; + } + #[rstest] #[tokio::test] async fn test_handle_get_sonarr_blocklist_event(#[values(true, false)] use_custom_sorting: bool) { From 97dc5054e94666e0fe369a089002f8686ba6c07b Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Mon, 25 Nov 2024 15:23:28 -0700 Subject: [PATCH 109/119] feat(cli): Support for editing Sonarr indexers --- src/cli/sonarr/edit_command_handler.rs | 137 ++++++++- src/cli/sonarr/edit_command_handler_tests.rs | 298 +++++++++++++++++++ 2 files changed, 433 insertions(+), 2 deletions(-) diff --git a/src/cli/sonarr/edit_command_handler.rs b/src/cli/sonarr/edit_command_handler.rs index fe04b26..adeeafb 100644 --- a/src/cli/sonarr/edit_command_handler.rs +++ b/src/cli/sonarr/edit_command_handler.rs @@ -1,13 +1,14 @@ use std::sync::Arc; use anyhow::Result; -use clap::{ArgGroup, Subcommand}; +use clap::{ArgAction, ArgGroup, Subcommand}; use tokio::sync::Mutex; use crate::{ app::App, - cli::{CliCommandHandler, Command}, + cli::{mutex_flags_or_option, CliCommandHandler, Command}, models::{ + servarr_models::EditIndexerParams, sonarr_models::{IndexerSettings, SonarrSerdeable}, Serdeable, }, @@ -56,6 +57,97 @@ pub enum SonarrEditCommand { )] rss_sync_interval: Option, }, + #[command( + about = "Edit preferences for the specified indexer", + group( + ArgGroup::new("edit_indexer") + .args([ + "name", + "enable_rss", + "disable_rss", + "enable_automatic_search", + "disable_automatic_search", + "enable_interactive_search", + "disable_automatic_search", + "url", + "api_key", + "seed_ratio", + "tag", + "priority", + "clear_tags" + ]).required(true) + .multiple(true)) + )] + Indexer { + #[arg( + long, + help = "The ID of the indexer whose settings you wish to edit", + required = true + )] + indexer_id: i64, + #[arg(long, help = "The name of the indexer")] + name: Option, + #[arg( + long, + help = "Indicate to Sonarr that this indexer should be used when Sonarr periodically looks for releases via RSS Sync", + conflicts_with = "disable_rss" + )] + enable_rss: bool, + #[arg( + long, + help = "Disable using this indexer when Sonarr periodically looks for releases via RSS Sync", + conflicts_with = "enable_rss" + )] + disable_rss: bool, + #[arg( + long, + help = "Indicate to Sonarr that this indexer should be used when automatic searches are performed via the UI or by Sonarr", + conflicts_with = "disable_automatic_search" + )] + enable_automatic_search: bool, + #[arg( + long, + help = "Disable using this indexer whenever automatic searches are performed via the UI or by Sonarr", + conflicts_with = "enable_automatic_search" + )] + disable_automatic_search: bool, + #[arg( + long, + help = "Indicate to Sonarr that this indexer should be used when an interactive search is used", + conflicts_with = "disable_interactive_search" + )] + enable_interactive_search: bool, + #[arg( + long, + help = "Disable using this indexer whenever an interactive search is performed", + conflicts_with = "enable_interactive_search" + )] + disable_interactive_search: bool, + #[arg(long, help = "The URL of the indexer")] + url: Option, + #[arg(long, help = "The API key used to access the indexer's API")] + api_key: Option, + #[arg( + long, + help = "The ratio a torrent should reach before stopping; Empty uses the download client's default. Ratio should be at least 1.0 and follow the indexer's rules" + )] + seed_ratio: Option, + #[arg( + long, + help = "Only use this indexer for series with at least one matching tag ID. Leave blank to use with all series.", + value_parser, + action = ArgAction::Append, + conflicts_with = "clear_tags" + )] + tag: Option>, + #[arg( + long, + help = "Indexer Priority from 1 (Highest) to 50 (Lowest). Default: 25. Used when grabbing releases as a tiebreaker for otherwise equal releases, Sonarr will still use all enabled indexers for RSS Sync and Searching" + )] + priority: Option, + #[arg(long, help = "Clear all tags on this indexer", conflicts_with = "tag")] + clear_tags: bool, + }, } impl From for Command { @@ -113,6 +205,47 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, SonarrEditCommand> for SonarrEditCommandH String::new() } } + SonarrEditCommand::Indexer { + indexer_id, + name, + enable_rss, + disable_rss, + enable_automatic_search, + disable_automatic_search, + enable_interactive_search, + disable_interactive_search, + url, + api_key, + seed_ratio, + tag, + priority, + clear_tags, + } => { + let rss_value = mutex_flags_or_option(enable_rss, disable_rss); + let automatic_search_value = + mutex_flags_or_option(enable_automatic_search, disable_automatic_search); + let interactive_search_value = + mutex_flags_or_option(enable_interactive_search, disable_interactive_search); + let edit_indexer_params = EditIndexerParams { + indexer_id, + name, + enable_rss: rss_value, + enable_automatic_search: automatic_search_value, + enable_interactive_search: interactive_search_value, + url, + api_key, + seed_ratio, + tags: tag, + priority, + clear_tags, + }; + + self + .network + .handle_network_event(SonarrEvent::EditIndexer(Some(edit_indexer_params)).into()) + .await?; + "Indexer updated".to_owned() + } }; Ok(result) diff --git a/src/cli/sonarr/edit_command_handler_tests.rs b/src/cli/sonarr/edit_command_handler_tests.rs index e8bb470..352e373 100644 --- a/src/cli/sonarr/edit_command_handler_tests.rs +++ b/src/cli/sonarr/edit_command_handler_tests.rs @@ -114,6 +114,250 @@ mod tests { assert_eq!(edit_command, expected_args); } } + + #[test] + fn test_edit_indexer_requires_arguments() { + let result = Cli::command().try_get_matches_from(["managarr", "sonarr", "edit", "indexer"]); + + assert!(result.is_err()); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_edit_indexer_with_indexer_id_still_requires_arguments() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "sonarr", + "edit", + "indexer", + "--indexer-id", + "1", + ]); + + assert!(result.is_err()); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_edit_indexer_rss_flags_conflict() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "sonarr", + "edit", + "indexer", + "--indexer-id", + "1", + "--enable-rss", + "--disable-rss", + ]); + + assert!(result.is_err()); + assert_eq!(result.unwrap_err().kind(), ErrorKind::ArgumentConflict); + } + + #[test] + fn test_edit_indexer_automatic_search_flags_conflict() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "sonarr", + "edit", + "indexer", + "--indexer-id", + "1", + "--enable-automatic-search", + "--disable-automatic-search", + ]); + + assert!(result.is_err()); + assert_eq!(result.unwrap_err().kind(), ErrorKind::ArgumentConflict); + } + + #[test] + fn test_edit_indexer_interactive_search_flags_conflict() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "sonarr", + "edit", + "indexer", + "--indexer-id", + "1", + "--enable-interactive-search", + "--disable-interactive-search", + ]); + + assert!(result.is_err()); + assert_eq!(result.unwrap_err().kind(), ErrorKind::ArgumentConflict); + } + + #[test] + fn test_edit_indexer_tag_flags_conflict() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "sonarr", + "edit", + "indexer", + "--indexer-id", + "1", + "--tag", + "1", + "--clear-tags", + ]); + + assert!(result.is_err()); + assert_eq!(result.unwrap_err().kind(), ErrorKind::ArgumentConflict); + } + + #[rstest] + fn test_edit_indexer_assert_argument_flags_require_args( + #[values("--name", "--url", "--api-key", "--seed-ratio", "--tag", "--priority")] flag: &str, + ) { + let result = Cli::command().try_get_matches_from([ + "managarr", + "sonarr", + "edit", + "indexer", + "--indexer-id", + "1", + flag, + ]); + + assert!(result.is_err()); + assert_eq!(result.unwrap_err().kind(), ErrorKind::InvalidValue); + } + + #[test] + fn test_edit_indexer_only_requires_at_least_one_argument_plus_indexer_id() { + let expected_args = SonarrEditCommand::Indexer { + indexer_id: 1, + name: Some("Test".to_owned()), + enable_rss: false, + disable_rss: false, + enable_automatic_search: false, + disable_automatic_search: false, + enable_interactive_search: false, + disable_interactive_search: false, + url: None, + api_key: None, + seed_ratio: None, + tag: None, + priority: None, + clear_tags: false, + }; + + let result = Cli::try_parse_from([ + "managarr", + "sonarr", + "edit", + "indexer", + "--indexer-id", + "1", + "--name", + "Test", + ]); + + assert!(result.is_ok()); + + if let Some(Command::Sonarr(SonarrCommand::Edit(edit_command))) = result.unwrap().command { + assert_eq!(edit_command, expected_args); + } + } + + #[test] + fn test_edit_indexer_tag_argument_is_repeatable() { + let expected_args = SonarrEditCommand::Indexer { + indexer_id: 1, + name: None, + enable_rss: false, + disable_rss: false, + enable_automatic_search: false, + disable_automatic_search: false, + enable_interactive_search: false, + disable_interactive_search: false, + url: None, + api_key: None, + seed_ratio: None, + tag: Some(vec![1, 2]), + priority: None, + clear_tags: false, + }; + + let result = Cli::try_parse_from([ + "managarr", + "sonarr", + "edit", + "indexer", + "--indexer-id", + "1", + "--tag", + "1", + "--tag", + "2", + ]); + + assert!(result.is_ok()); + + if let Some(Command::Sonarr(SonarrCommand::Edit(edit_command))) = result.unwrap().command { + assert_eq!(edit_command, expected_args); + } + } + + #[test] + fn test_edit_indexer_all_arguments_defined() { + let expected_args = SonarrEditCommand::Indexer { + indexer_id: 1, + name: Some("Test".to_owned()), + enable_rss: true, + disable_rss: false, + enable_automatic_search: true, + disable_automatic_search: false, + enable_interactive_search: true, + disable_interactive_search: false, + url: Some("http://test.com".to_owned()), + api_key: Some("testKey".to_owned()), + seed_ratio: Some("1.2".to_owned()), + tag: Some(vec![1, 2]), + priority: Some(25), + clear_tags: false, + }; + + let result = Cli::try_parse_from([ + "managarr", + "sonarr", + "edit", + "indexer", + "--indexer-id", + "1", + "--name", + "Test", + "--enable-rss", + "--enable-automatic-search", + "--enable-interactive-search", + "--url", + "http://test.com", + "--api-key", + "testKey", + "--seed-ratio", + "1.2", + "--tag", + "1", + "--tag", + "2", + "--priority", + "25", + ]); + + assert!(result.is_ok()); + + if let Some(Command::Sonarr(SonarrCommand::Edit(edit_command))) = result.unwrap().command { + assert_eq!(edit_command, expected_args); + } + } } mod handler { @@ -130,6 +374,7 @@ mod tests { CliCommandHandler, }, models::{ + servarr_models::EditIndexerParams, sonarr_models::{IndexerSettings, SonarrSerdeable}, Serdeable, }, @@ -192,5 +437,58 @@ mod tests { assert!(result.is_ok()); } + + #[tokio::test] + async fn test_handle_edit_indexer_command() { + let expected_edit_indexer_params = EditIndexerParams { + indexer_id: 1, + name: Some("Test".to_owned()), + enable_rss: Some(true), + enable_automatic_search: Some(true), + enable_interactive_search: Some(true), + url: Some("http://test.com".to_owned()), + api_key: Some("testKey".to_owned()), + seed_ratio: Some("1.2".to_owned()), + tags: Some(vec![1, 2]), + priority: Some(25), + clear_tags: false, + }; + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::( + SonarrEvent::EditIndexer(Some(expected_edit_indexer_params)).into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Sonarr(SonarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::default())); + let edit_indexer_command = SonarrEditCommand::Indexer { + indexer_id: 1, + name: Some("Test".to_owned()), + enable_rss: true, + disable_rss: false, + enable_automatic_search: true, + disable_automatic_search: false, + enable_interactive_search: true, + disable_interactive_search: false, + url: Some("http://test.com".to_owned()), + api_key: Some("testKey".to_owned()), + seed_ratio: Some("1.2".to_owned()), + tag: Some(vec![1, 2]), + priority: Some(25), + clear_tags: false, + }; + + let result = + SonarrEditCommandHandler::with(&app_arc, edit_indexer_command, &mut mock_network) + .handle() + .await; + + assert!(result.is_ok()); + } } } From 06c9baf8dff3431cd9bc5d0ab067158d114188c8 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Mon, 25 Nov 2024 15:44:07 -0700 Subject: [PATCH 110/119] feat(models): Created the EditSeriesModal --- src/models/servarr_data/sonarr/modals.rs | 106 ++++++++++++++++- .../servarr_data/sonarr/modals_tests.rs | 109 ++++++++++++++++++ src/models/servarr_data/sonarr/sonarr_data.rs | 4 +- .../servarr_data/sonarr/sonarr_data_tests.rs | 1 + src/models/sonarr_models.rs | 14 +++ 5 files changed, 232 insertions(+), 2 deletions(-) diff --git a/src/models/servarr_data/sonarr/modals.rs b/src/models/servarr_data/sonarr/modals.rs index 208c9a0..d8b53d5 100644 --- a/src/models/servarr_data/sonarr/modals.rs +++ b/src/models/servarr_data/sonarr/modals.rs @@ -3,7 +3,7 @@ use strum::IntoEnumIterator; use crate::models::{ servarr_data::modals::EditIndexerModal, servarr_models::{Indexer, RootFolder}, - sonarr_models::{Episode, SeriesMonitor, SeriesType, SonarrHistoryItem, SonarrRelease}, + sonarr_models::{Episode, Series, SeriesMonitor, SeriesType, SonarrHistoryItem, SonarrRelease}, stateful_list::StatefulList, stateful_table::StatefulTable, HorizontallyScrollableText, ScrollableText, @@ -142,6 +142,110 @@ impl From<&SonarrData> for EditIndexerModal { } } +#[derive(Default)] +pub struct EditSeriesModal { + pub series_type_list: StatefulList, + pub quality_profile_list: StatefulList, + pub language_profile_list: StatefulList, + pub monitored: Option, + pub use_season_folders: Option, + pub path: HorizontallyScrollableText, + pub tags: HorizontallyScrollableText, +} + +impl From<&SonarrData> for EditSeriesModal { + fn from(sonarr_data: &SonarrData) -> EditSeriesModal { + let mut edit_series_modal = EditSeriesModal::default(); + let Series { + path, + tags, + monitored, + season_folder, + series_type, + quality_profile_id, + language_profile_id, + .. + } = sonarr_data.series.current_selection(); + + edit_series_modal + .series_type_list + .set_items(Vec::from_iter(SeriesType::iter())); + edit_series_modal.path = path.clone().into(); + edit_series_modal.tags = tags + .iter() + .map(|tag_id| { + sonarr_data + .tags_map + .get_by_left(&tag_id.as_i64().unwrap()) + .unwrap() + .clone() + }) + .collect::>() + .join(", ") + .into(); + + edit_series_modal.monitored = Some(*monitored); + edit_series_modal.use_season_folders = Some(*season_folder); + + let series_type_index = edit_series_modal + .series_type_list + .items + .iter() + .position(|st| st == series_type); + edit_series_modal + .series_type_list + .state + .select(series_type_index); + + let mut quality_profile_names: Vec = sonarr_data + .quality_profile_map + .right_values() + .cloned() + .collect(); + quality_profile_names.sort(); + edit_series_modal + .quality_profile_list + .set_items(quality_profile_names); + let quality_profile_name = sonarr_data + .quality_profile_map + .get_by_left(quality_profile_id) + .unwrap(); + let quality_profile_index = edit_series_modal + .quality_profile_list + .items + .iter() + .position(|profile| profile == quality_profile_name); + edit_series_modal + .quality_profile_list + .state + .select(quality_profile_index); + let mut language_profile_names: Vec = sonarr_data + .language_profiles_map + .right_values() + .cloned() + .collect(); + language_profile_names.sort(); + edit_series_modal + .language_profile_list + .set_items(language_profile_names); + let language_profile_name = sonarr_data + .language_profiles_map + .get_by_left(language_profile_id) + .unwrap(); + let language_profile_index = edit_series_modal + .language_profile_list + .items + .iter() + .position(|profile| profile == language_profile_name); + edit_series_modal + .language_profile_list + .state + .select(language_profile_index); + + edit_series_modal + } +} + #[derive(Default)] pub struct EpisodeDetailsModal { pub episode_details: ScrollableText, diff --git a/src/models/servarr_data/sonarr/modals_tests.rs b/src/models/servarr_data/sonarr/modals_tests.rs index 0dfd63c..96af39f 100644 --- a/src/models/servarr_data/sonarr/modals_tests.rs +++ b/src/models/servarr_data/sonarr/modals_tests.rs @@ -5,12 +5,14 @@ mod tests { use rstest::rstest; use strum::IntoEnumIterator; + use crate::models::servarr_data::sonarr::modals::EditSeriesModal; use crate::models::servarr_models::{Indexer, IndexerField}; use crate::models::{ servarr_data::sonarr::{modals::AddSeriesModal, sonarr_data::SonarrData}, servarr_models::RootFolder, sonarr_models::{SeriesMonitor, SeriesType}, }; + use crate::models::{sonarr_models::Series, stateful_table::StatefulTable}; use serde_json::{Number, Value}; use crate::models::servarr_data::modals::EditIndexerModal; @@ -112,4 +114,111 @@ mod tests { assert!(edit_indexer_modal.seed_ratio.text.is_empty()); } } + + #[test] + fn test_edit_indexer_modal_from_sonarr_data_seed_ratio_value_is_none() { + let mut sonarr_data = SonarrData { + tags_map: BiMap::from_iter([(1, "usenet".to_owned()), (2, "test".to_owned())]), + ..SonarrData::default() + }; + let fields = vec![ + IndexerField { + name: Some("baseUrl".to_owned()), + value: Some(Value::String("https://test.com".to_owned())), + }, + IndexerField { + name: Some("apiKey".to_owned()), + value: Some(Value::String("1234".to_owned())), + }, + IndexerField { + name: Some("seedCriteria.seedRatio".to_owned()), + value: None, + }, + ]; + + let indexer = Indexer { + name: Some("Test".to_owned()), + enable_rss: true, + enable_automatic_search: true, + enable_interactive_search: true, + tags: vec![Number::from(1), Number::from(2)], + fields: Some(fields), + ..Indexer::default() + }; + sonarr_data.indexers.set_items(vec![indexer]); + + let edit_indexer_modal = EditIndexerModal::from(&sonarr_data); + + assert_str_eq!(edit_indexer_modal.name.text, "Test"); + assert_eq!(edit_indexer_modal.enable_rss, Some(true)); + assert_eq!(edit_indexer_modal.enable_automatic_search, Some(true)); + assert_eq!(edit_indexer_modal.enable_interactive_search, Some(true)); + assert_str_eq!(edit_indexer_modal.url.text, "https://test.com"); + assert_str_eq!(edit_indexer_modal.api_key.text, "1234"); + assert!(edit_indexer_modal.seed_ratio.text.is_empty()); + } + + #[rstest] + fn test_edit_series_modal_from_sonarr_data(#[values(true, false)] test_filtered_series: bool) { + let mut sonarr_data = SonarrData { + quality_profile_map: BiMap::from_iter([ + (2222, "HD - 1080p".to_owned()), + (1111, "Any".to_owned()), + ]), + language_profiles_map: BiMap::from_iter([ + (2222, "English".to_owned()), + (1111, "Any".to_owned()), + ]), + tags_map: BiMap::from_iter([(1, "usenet".to_owned()), (2, "test".to_owned())]), + series: StatefulTable::default(), + ..SonarrData::default() + }; + let series = Series { + path: "/nfs/seriess/Test".to_owned(), + monitored: true, + season_folder: true, + quality_profile_id: 2222, + language_profile_id: 2222, + series_type: SeriesType::Anime, + tags: vec![Number::from(1), Number::from(2)], + ..Series::default() + }; + + if test_filtered_series { + sonarr_data.series.set_filtered_items(vec![series]); + } else { + sonarr_data.series.set_items(vec![series]); + } + + let edit_series_modal = EditSeriesModal::from(&sonarr_data); + + assert_eq!( + edit_series_modal.series_type_list.items, + Vec::from_iter(SeriesType::iter()) + ); + assert_eq!( + edit_series_modal.series_type_list.current_selection(), + &SeriesType::Anime, + ); + assert_eq!( + edit_series_modal.quality_profile_list.items, + vec!["Any".to_owned(), "HD - 1080p".to_owned()] + ); + assert_str_eq!( + edit_series_modal.quality_profile_list.current_selection(), + "HD - 1080p" + ); + assert_eq!( + edit_series_modal.language_profile_list.items, + vec!["Any".to_owned(), "English".to_owned()] + ); + assert_str_eq!( + edit_series_modal.language_profile_list.current_selection(), + "English" + ); + assert_str_eq!(edit_series_modal.path.text, "/nfs/seriess/Test"); + assert_str_eq!(edit_series_modal.tags.text, "usenet, test"); + assert_eq!(edit_series_modal.monitored, Some(true)); + assert_eq!(edit_series_modal.use_season_folders, Some(true)); + } } diff --git a/src/models/servarr_data/sonarr/sonarr_data.rs b/src/models/servarr_data/sonarr/sonarr_data.rs index 76fbd78..1b34d4a 100644 --- a/src/models/servarr_data/sonarr/sonarr_data.rs +++ b/src/models/servarr_data/sonarr/sonarr_data.rs @@ -14,7 +14,7 @@ use crate::models::{ HorizontallyScrollableText, Route, ScrollableText, }; -use super::modals::{AddSeriesModal, SeasonDetailsModal}; +use super::modals::{AddSeriesModal, EditSeriesModal, SeasonDetailsModal}; #[cfg(test)] #[path = "sonarr_data_tests.rs"] @@ -31,6 +31,7 @@ pub struct SonarrData { pub disk_space_vec: Vec, pub edit_indexer_modal: Option, pub edit_root_folder: Option, + pub edit_series_modal: Option, pub history: StatefulTable, pub indexers: StatefulTable, pub indexer_settings: Option, @@ -72,6 +73,7 @@ impl Default for SonarrData { disk_space_vec: Vec::new(), edit_indexer_modal: None, edit_root_folder: None, + edit_series_modal: None, history: StatefulTable::default(), indexers: StatefulTable::default(), indexer_settings: None, diff --git a/src/models/servarr_data/sonarr/sonarr_data_tests.rs b/src/models/servarr_data/sonarr/sonarr_data_tests.rs index 30c7ee6..de9b25a 100644 --- a/src/models/servarr_data/sonarr/sonarr_data_tests.rs +++ b/src/models/servarr_data/sonarr/sonarr_data_tests.rs @@ -58,6 +58,7 @@ mod tests { assert!(sonarr_data.disk_space_vec.is_empty()); assert!(sonarr_data.edit_indexer_modal.is_none()); assert!(sonarr_data.edit_root_folder.is_none()); + assert!(sonarr_data.edit_series_modal.is_none()); assert!(sonarr_data.history.is_empty()); assert!(sonarr_data.indexers.is_empty()); assert!(sonarr_data.indexer_settings.is_none()); diff --git a/src/models/sonarr_models.rs b/src/models/sonarr_models.rs index 8b01276..d98d9a7 100644 --- a/src/models/sonarr_models.rs +++ b/src/models/sonarr_models.rs @@ -128,6 +128,20 @@ pub struct DownloadsResponse { pub records: Vec, } +#[derive(Default, Clone, Serialize, Debug, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct EditSeriesParams { + pub series_id: i64, + pub monitored: Option, + pub use_season_folders: Option, + pub quality_profile_id: Option, + pub language_profile_id: Option, + pub series_type: Option, + pub root_folder_path: Option, + pub tags: Option>, + pub clear_tags: bool, +} + #[derive(Default, Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct Episode { From c29e2ca9aeb7c659b6c4fd181ce5c035e506472a Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Mon, 25 Nov 2024 16:02:49 -0700 Subject: [PATCH 111/119] feat(network): Support for editing a series in Sonarr --- src/network/sonarr_network.rs | 205 +++++++++++++++++++++++++- src/network/sonarr_network_tests.rs | 216 +++++++++++++++++++++++++++- 2 files changed, 413 insertions(+), 8 deletions(-) diff --git a/src/network/sonarr_network.rs b/src/network/sonarr_network.rs index 20cbecc..02397fe 100644 --- a/src/network/sonarr_network.rs +++ b/src/network/sonarr_network.rs @@ -10,7 +10,7 @@ use crate::{ servarr_data::{ modals::{EditIndexerModal, IndexerTestResultModalItem}, sonarr::{ - modals::{AddSeriesModal, EpisodeDetailsModal, SeasonDetailsModal}, + modals::{AddSeriesModal, EditSeriesModal, EpisodeDetailsModal, SeasonDetailsModal}, sonarr_data::ActiveSonarrBlock, }, }, @@ -20,9 +20,10 @@ use crate::{ }, sonarr_models::{ AddSeriesBody, AddSeriesOptions, AddSeriesSearchResult, BlocklistResponse, - DeleteSeriesParams, DownloadRecord, DownloadsResponse, Episode, IndexerSettings, Series, - SonarrCommandBody, SonarrHistoryItem, SonarrHistoryWrapper, SonarrRelease, - SonarrReleaseDownloadBody, SonarrSerdeable, SonarrTask, SonarrTaskName, SystemStatus, + DeleteSeriesParams, DownloadRecord, DownloadsResponse, EditSeriesParams, Episode, + IndexerSettings, Series, SonarrCommandBody, SonarrHistoryItem, SonarrHistoryWrapper, + SonarrRelease, SonarrReleaseDownloadBody, SonarrSerdeable, SonarrTask, SonarrTaskName, + SystemStatus, }, stateful_table::StatefulTable, HorizontallyScrollableText, Route, Scrollable, ScrollableText, @@ -52,6 +53,7 @@ pub enum SonarrEvent { DownloadRelease(SonarrReleaseDownloadBody), EditAllIndexerSettings(Option), EditIndexer(Option), + EditSeries(Option), GetAllIndexerSettings, GetBlocklist, GetDownloads, @@ -134,7 +136,8 @@ impl NetworkResource for SonarrEvent { SonarrEvent::AddSeries(_) | SonarrEvent::ListSeries | SonarrEvent::GetSeriesDetails(_) - | SonarrEvent::DeleteSeries(_) => "/series", + | SonarrEvent::DeleteSeries(_) + | SonarrEvent::EditSeries(_) => "/series", SonarrEvent::SearchNewSeries(_) => "/series/lookup", SonarrEvent::MarkHistoryItemAsFailed(_) => "/history/failed", SonarrEvent::TestIndexer(_) => "/indexer/test", @@ -211,6 +214,10 @@ impl<'a, 'b> Network<'a, 'b> { .edit_sonarr_indexer(params) .await .map(SonarrSerdeable::from), + SonarrEvent::EditSeries(params) => self + .edit_sonarr_series(params) + .await + .map(SonarrSerdeable::from), SonarrEvent::GetBlocklist => self.get_sonarr_blocklist().await.map(SonarrSerdeable::from), SonarrEvent::GetDownloads => self.get_sonarr_downloads().await.map(SonarrSerdeable::from), SonarrEvent::GetEpisodes(series_id) => self @@ -1062,6 +1069,194 @@ impl<'a, 'b> Network<'a, 'b> { .await } + async fn edit_sonarr_series( + &mut self, + edit_series_params: Option, + ) -> Result<()> { + info!("Editing Sonarr series"); + let detail_event = SonarrEvent::GetSeriesDetails(None); + let event = SonarrEvent::EditSeries(None); + + let (series_id, _) = if let Some(ref params) = edit_series_params { + self.extract_series_id(Some(params.series_id)).await + } else { + self.extract_series_id(None).await + }; + info!("Fetching series details for series with ID: {series_id}"); + + let request_props = self + .request_props_from( + detail_event, + RequestMethod::Get, + None::<()>, + Some(format!("/{series_id}")), + None, + ) + .await; + + let mut response = String::new(); + + self + .handle_request::<(), Value>(request_props, |detailed_series_body, _| { + response = detailed_series_body.to_string() + }) + .await?; + + info!("Constructing edit series body"); + + let mut detailed_series_body: Value = serde_json::from_str(&response).unwrap(); + let ( + monitored, + use_season_folders, + series_type, + quality_profile_id, + language_profile_id, + root_folder_path, + tags, + ) = if let Some(params) = edit_series_params { + let monitored = params.monitored.unwrap_or( + detailed_series_body["monitored"] + .as_bool() + .expect("Unable to deserialize 'monitored'"), + ); + let use_season_folders = params.use_season_folders.unwrap_or( + detailed_series_body["seasonFolder"] + .as_bool() + .expect("Unable to deserialize 'season_folder'"), + ); + let series_type = params + .series_type + .unwrap_or_else(|| { + serde_json::from_value(detailed_series_body["seriesType"].clone()) + .expect("Unable to deserialize 'seriesType'") + }) + .to_string(); + let quality_profile_id = params.quality_profile_id.unwrap_or_else(|| { + detailed_series_body["qualityProfileId"] + .as_i64() + .expect("Unable to deserialize 'qualityProfileId'") + }); + let language_profile_id = params.language_profile_id.unwrap_or_else(|| { + detailed_series_body["languageProfileId"] + .as_i64() + .expect("Unable to deserialize 'languageProfileId'") + }); + let root_folder_path = params.root_folder_path.unwrap_or_else(|| { + detailed_series_body["path"] + .as_str() + .expect("Unable to deserialize 'path'") + .to_owned() + }); + let tags = if params.clear_tags { + vec![] + } else { + params.tags.unwrap_or( + detailed_series_body["tags"] + .as_array() + .expect("Unable to deserialize 'tags'") + .iter() + .map(|item| item.as_i64().expect("Unable to deserialize tag ID")) + .collect(), + ) + }; + + ( + monitored, + use_season_folders, + series_type, + quality_profile_id, + language_profile_id, + root_folder_path, + tags, + ) + } else { + let tags = self + .app + .lock() + .await + .data + .sonarr_data + .edit_series_modal + .as_ref() + .unwrap() + .tags + .text + .clone(); + let tag_ids_vec = self.extract_and_add_sonarr_tag_ids_vec(tags).await; + let mut app = self.app.lock().await; + + let params = { + let EditSeriesModal { + monitored, + use_season_folders, + path, + series_type_list, + quality_profile_list, + language_profile_list, + .. + } = app.data.sonarr_data.edit_series_modal.as_ref().unwrap(); + let quality_profile = quality_profile_list.current_selection(); + let quality_profile_id = *app + .data + .sonarr_data + .quality_profile_map + .iter() + .filter(|(_, value)| *value == quality_profile) + .map(|(key, _)| key) + .next() + .unwrap(); + let language_profile = language_profile_list.current_selection(); + let language_profile_id = *app + .data + .sonarr_data + .language_profiles_map + .iter() + .filter(|(_, value)| *value == language_profile) + .map(|(key, _)| key) + .next() + .unwrap(); + + ( + monitored.unwrap_or_default(), + use_season_folders.unwrap_or_default(), + series_type_list.current_selection().to_string(), + quality_profile_id, + language_profile_id, + path.text.clone(), + tag_ids_vec, + ) + }; + + app.data.sonarr_data.edit_series_modal = None; + + params + }; + + *detailed_series_body.get_mut("monitored").unwrap() = json!(monitored); + *detailed_series_body.get_mut("seasonFolder").unwrap() = json!(use_season_folders); + *detailed_series_body.get_mut("seriesType").unwrap() = json!(series_type); + *detailed_series_body.get_mut("qualityProfileId").unwrap() = json!(quality_profile_id); + *detailed_series_body.get_mut("languageProfileId").unwrap() = json!(language_profile_id); + *detailed_series_body.get_mut("path").unwrap() = json!(root_folder_path); + *detailed_series_body.get_mut("tags").unwrap() = json!(tags); + + debug!("Edit series body: {detailed_series_body:?}"); + + let request_props = self + .request_props_from( + event, + RequestMethod::Put, + Some(detailed_series_body), + Some(format!("/{series_id}")), + None, + ) + .await; + + self + .handle_request::(request_props, |_, _| ()) + .await + } + async fn get_all_sonarr_indexer_settings(&mut self) -> Result { info!("Fetching Sonarr indexer settings"); let event = SonarrEvent::GetAllIndexerSettings; diff --git a/src/network/sonarr_network_tests.rs b/src/network/sonarr_network_tests.rs index 16ad396..3a3acd8 100644 --- a/src/network/sonarr_network_tests.rs +++ b/src/network/sonarr_network_tests.rs @@ -17,14 +17,14 @@ mod test { use crate::models::sonarr_models::{ AddSeriesBody, AddSeriesOptions, AddSeriesSearchResult, AddSeriesSearchResultStatistics, - IndexerSettings, SeriesMonitor, + EditSeriesParams, IndexerSettings, SeriesMonitor, }; use crate::app::{App, ServarrConfig}; use crate::models::radarr_models::IndexerTestResult; use crate::models::servarr_data::modals::{EditIndexerModal, IndexerTestResultModalItem}; use crate::models::servarr_data::sonarr::modals::{ - AddSeriesModal, EpisodeDetailsModal, SeasonDetailsModal, + AddSeriesModal, EditSeriesModal, EpisodeDetailsModal, SeasonDetailsModal, }; use crate::models::servarr_data::sonarr::sonarr_data::ActiveSonarrBlock; use crate::models::servarr_models::{ @@ -160,7 +160,8 @@ mod test { SonarrEvent::AddSeries(None), SonarrEvent::ListSeries, SonarrEvent::GetSeriesDetails(None), - SonarrEvent::DeleteSeries(None) + SonarrEvent::DeleteSeries(None), + SonarrEvent::EditSeries(None) )] event: SonarrEvent, ) { @@ -1690,6 +1691,215 @@ mod test { async_edit_server.assert_async().await; } + #[tokio::test] + async fn test_handle_edit_series_event() { + let mut expected_body: Value = serde_json::from_str(SERIES_JSON).unwrap(); + *expected_body.get_mut("monitored").unwrap() = json!(false); + *expected_body.get_mut("seasonFolder").unwrap() = json!(false); + *expected_body.get_mut("seriesType").unwrap() = json!("standard"); + *expected_body.get_mut("qualityProfileId").unwrap() = json!(1111); + *expected_body.get_mut("languageProfileId").unwrap() = json!(1111); + *expected_body.get_mut("path").unwrap() = json!("/nfs/Test Path"); + *expected_body.get_mut("tags").unwrap() = json!([1, 2]); + + let (async_details_server, app_arc, mut server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(serde_json::from_str(SERIES_JSON).unwrap()), + None, + SonarrEvent::GetSeriesDetails(None), + Some("/1"), + None, + ) + .await; + let async_edit_server = server + .mock( + "PUT", + format!("/api/v3{}/1", SonarrEvent::EditSeries(None).resource()).as_str(), + ) + .with_status(202) + .match_header("X-Api-Key", "test1234") + .match_body(Matcher::Json(expected_body)) + .create_async() + .await; + { + let mut app = app_arc.lock().await; + app.data.sonarr_data.tags_map = + BiMap::from_iter([(1, "usenet".to_owned()), (2, "testing".to_owned())]); + let mut edit_series = EditSeriesModal { + tags: "usenet, testing".to_owned().into(), + path: "/nfs/Test Path".to_owned().into(), + monitored: Some(false), + use_season_folders: Some(false), + ..EditSeriesModal::default() + }; + edit_series + .quality_profile_list + .set_items(vec!["Any".to_owned(), "HD - 1080p".to_owned()]); + edit_series + .language_profile_list + .set_items(vec!["Any".to_owned(), "English".to_owned()]); + edit_series + .series_type_list + .set_items(Vec::from_iter(SeriesType::iter())); + app.data.sonarr_data.edit_series_modal = Some(edit_series); + app.data.sonarr_data.series.set_items(vec![Series { + monitored: false, + season_folder: false, + ..series() + }]); + app.data.sonarr_data.quality_profile_map = + BiMap::from_iter([(1111, "Any".to_owned()), (2222, "HD - 1080p".to_owned())]); + app.data.sonarr_data.language_profiles_map = + BiMap::from_iter([(1111, "Any".to_owned()), (2222, "English".to_owned())]); + } + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + assert!(network + .handle_sonarr_event(SonarrEvent::EditSeries(None)) + .await + .is_ok()); + + async_details_server.assert_async().await; + async_edit_server.assert_async().await; + + let app = app_arc.lock().await; + assert!(app.data.sonarr_data.edit_series_modal.is_none()); + } + + #[tokio::test] + async fn test_handle_edit_series_event_uses_provided_parameters() { + let mut expected_body: Value = serde_json::from_str(SERIES_JSON).unwrap(); + *expected_body.get_mut("monitored").unwrap() = json!(false); + *expected_body.get_mut("seasonFolder").unwrap() = json!(false); + *expected_body.get_mut("seriesType").unwrap() = json!("standard"); + *expected_body.get_mut("qualityProfileId").unwrap() = json!(1111); + *expected_body.get_mut("languageProfileId").unwrap() = json!(1111); + *expected_body.get_mut("path").unwrap() = json!("/nfs/Test Path"); + *expected_body.get_mut("tags").unwrap() = json!([1, 2]); + + let (async_details_server, app_arc, mut server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(serde_json::from_str(SERIES_JSON).unwrap()), + None, + SonarrEvent::GetSeriesDetails(None), + Some("/1"), + None, + ) + .await; + let async_edit_server = server + .mock( + "PUT", + format!("/api/v3{}/1", SonarrEvent::EditSeries(None).resource()).as_str(), + ) + .with_status(202) + .match_header("X-Api-Key", "test1234") + .match_body(Matcher::Json(expected_body)) + .create_async() + .await; + let edit_series_params = EditSeriesParams { + series_id: 1, + monitored: Some(false), + use_season_folders: Some(false), + series_type: Some(SeriesType::Standard), + quality_profile_id: Some(1111), + language_profile_id: Some(1111), + root_folder_path: Some("/nfs/Test Path".to_owned()), + tags: Some(vec![1, 2]), + ..EditSeriesParams::default() + }; + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + assert!(network + .handle_sonarr_event(SonarrEvent::EditSeries(Some(edit_series_params))) + .await + .is_ok()); + + async_details_server.assert_async().await; + async_edit_server.assert_async().await; + } + + #[tokio::test] + async fn test_handle_edit_series_event_uses_provided_parameters_defaults_to_previous_values() { + let expected_body: Value = serde_json::from_str(SERIES_JSON).unwrap(); + let (async_details_server, app_arc, mut server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(serde_json::from_str(SERIES_JSON).unwrap()), + None, + SonarrEvent::GetSeriesDetails(None), + Some("/1"), + None, + ) + .await; + let async_edit_server = server + .mock( + "PUT", + format!("/api/v3{}/1", SonarrEvent::EditSeries(None).resource()).as_str(), + ) + .with_status(202) + .match_header("X-Api-Key", "test1234") + .match_body(Matcher::Json(expected_body)) + .create_async() + .await; + let edit_series_params = EditSeriesParams { + series_id: 1, + ..EditSeriesParams::default() + }; + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + assert!(network + .handle_sonarr_event(SonarrEvent::EditSeries(Some(edit_series_params))) + .await + .is_ok()); + + async_details_server.assert_async().await; + async_edit_server.assert_async().await; + } + + #[tokio::test] + async fn test_handle_edit_series_event_uses_provided_parameters_returns_empty_tags_vec_when_clear_tags_is_true( + ) { + let mut expected_body: Value = serde_json::from_str(SERIES_JSON).unwrap(); + *expected_body.get_mut("tags").unwrap() = json!([]); + + let (async_details_server, app_arc, mut server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(serde_json::from_str(SERIES_JSON).unwrap()), + None, + SonarrEvent::GetSeriesDetails(None), + Some("/1"), + None, + ) + .await; + let async_edit_server = server + .mock( + "PUT", + format!("/api/v3{}/1", SonarrEvent::EditSeries(None).resource()).as_str(), + ) + .with_status(202) + .match_header("X-Api-Key", "test1234") + .match_body(Matcher::Json(expected_body)) + .create_async() + .await; + let edit_series_params = EditSeriesParams { + series_id: 1, + clear_tags: true, + ..EditSeriesParams::default() + }; + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + assert!(network + .handle_sonarr_event(SonarrEvent::EditSeries(Some(edit_series_params))) + .await + .is_ok()); + + async_details_server.assert_async().await; + async_edit_server.assert_async().await; + } + #[rstest] #[tokio::test] async fn test_handle_get_sonarr_blocklist_event(#[values(true, false)] use_custom_sorting: bool) { From 3af22cceac127441b07fc7bea65d7d497f7c60ab Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Mon, 25 Nov 2024 16:04:35 -0700 Subject: [PATCH 112/119] feat(models): Added the ActiveSonarrBlocks for editing a series --- src/models/servarr_data/sonarr/sonarr_data.rs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/models/servarr_data/sonarr/sonarr_data.rs b/src/models/servarr_data/sonarr/sonarr_data.rs index 1b34d4a..82b7198 100644 --- a/src/models/servarr_data/sonarr/sonarr_data.rs +++ b/src/models/servarr_data/sonarr/sonarr_data.rs @@ -133,6 +133,14 @@ pub enum ActiveSonarrBlock { Downloads, EditIndexerPrompt, EditSeriesPrompt, + EditSeriesConfirmPrompt, + EditSeriesPathInput, + EditSeriesSelectSeriesType, + EditSeriesSelectQualityProfile, + EditSeriesSelectLanguageProfile, + EditSeriesTagsInput, + EditSeriesToggleMonitored, + EditSeriesToggleSeasonFolder, EpisodeDetails, EpisodeFile, EpisodeHistory, From ee312a21ebdcf7a75d5c20232479faa53a18379a Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Mon, 25 Nov 2024 16:19:48 -0700 Subject: [PATCH 113/119] feat(cli): Support for editing a sonarr series --- src/cli/sonarr/edit_command_handler.rs | 112 +++++- src/cli/sonarr/edit_command_handler_tests.rs | 384 ++++++++++++++++++- 2 files changed, 493 insertions(+), 3 deletions(-) diff --git a/src/cli/sonarr/edit_command_handler.rs b/src/cli/sonarr/edit_command_handler.rs index adeeafb..bd879c3 100644 --- a/src/cli/sonarr/edit_command_handler.rs +++ b/src/cli/sonarr/edit_command_handler.rs @@ -9,7 +9,7 @@ use crate::{ cli::{mutex_flags_or_option, CliCommandHandler, Command}, models::{ servarr_models::EditIndexerParams, - sonarr_models::{IndexerSettings, SonarrSerdeable}, + sonarr_models::{EditSeriesParams, IndexerSettings, SeriesType, SonarrSerdeable}, Serdeable, }, network::{sonarr_network::SonarrEvent, NetworkTrait}, @@ -148,6 +148,82 @@ pub enum SonarrEditCommand { #[arg(long, help = "Clear all tags on this indexer", conflicts_with = "tag")] clear_tags: bool, }, + #[command( + about = "Edit preferences for the specified series", + group( + ArgGroup::new("edit_series") + .args([ + "enable_monitoring", + "disable_monitoring", + "enable_season_folders", + "disable_season_folders", + "series_type", + "quality_profile_id", + "language_profile_id", + "root_folder_path", + "tag", + "clear_tags" + ]).required(true) + .multiple(true)) + )] + Series { + #[arg( + long, + help = "The ID of the series whose settings you want to edit", + required = true + )] + series_id: i64, + #[arg( + long, + help = "Enable monitoring of this series in Sonarr so Sonarr will automatically download this series if it is available", + conflicts_with = "disable_monitoring" + )] + enable_monitoring: bool, + #[arg( + long, + help = "Disable monitoring of this series so Sonarr does not automatically download the series if it is found to be available", + conflicts_with = "enable_monitoring" + )] + disable_monitoring: bool, + #[arg( + long, + help = "The minimum availability to monitor for this film", + value_enum + )] + #[arg( + long, + help = "Enable sorting episodes of this series into season folders", + conflicts_with = "disable_season_folders" + )] + enable_season_folders: bool, + #[arg( + long, + help = "Disable sorting episodes of this series into season folders", + conflicts_with = "enable_season_folders" + )] + disable_season_folders: bool, + #[arg(long, help = "The type of series", value_enum)] + series_type: Option, + #[arg(long, help = "The ID of the quality profile to use for this series")] + quality_profile_id: Option, + #[arg(long, help = "The ID of the language profile to use for this series")] + language_profile_id: Option, + #[arg( + long, + help = "The root folder path where all film data and metadata should live" + )] + root_folder_path: Option, + #[arg( + long, + help = "Tag IDs to tag this series with", + value_parser, + action = ArgAction::Append, + conflicts_with = "clear_tags" + )] + tag: Option>, + #[arg(long, help = "Clear all tags on this series", conflicts_with = "tag")] + clear_tags: bool, + }, } impl From for Command { @@ -246,6 +322,40 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, SonarrEditCommand> for SonarrEditCommandH .await?; "Indexer updated".to_owned() } + SonarrEditCommand::Series { + series_id, + enable_monitoring, + disable_monitoring, + enable_season_folders, + disable_season_folders, + series_type, + quality_profile_id, + language_profile_id, + root_folder_path, + tag, + clear_tags, + } => { + let monitored_value = mutex_flags_or_option(enable_monitoring, disable_monitoring); + let season_folders_value = + mutex_flags_or_option(enable_season_folders, disable_season_folders); + let edit_series_params = EditSeriesParams { + series_id, + monitored: monitored_value, + use_season_folders: season_folders_value, + series_type, + quality_profile_id, + language_profile_id, + root_folder_path, + tags: tag, + clear_tags, + }; + + self + .network + .handle_network_event(SonarrEvent::EditSeries(Some(edit_series_params)).into()) + .await?; + "Series Updated".to_owned() + } }; Ok(result) diff --git a/src/cli/sonarr/edit_command_handler_tests.rs b/src/cli/sonarr/edit_command_handler_tests.rs index 352e373..eaef63f 100644 --- a/src/cli/sonarr/edit_command_handler_tests.rs +++ b/src/cli/sonarr/edit_command_handler_tests.rs @@ -20,7 +20,7 @@ mod tests { } mod cli { - use crate::Cli; + use crate::{models::sonarr_models::SeriesType, Cli}; use super::*; use clap::{error::ErrorKind, CommandFactory, Parser}; @@ -358,6 +358,245 @@ mod tests { assert_eq!(edit_command, expected_args); } } + + #[test] + fn test_edit_series_requires_arguments() { + let result = Cli::command().try_get_matches_from(["managarr", "sonarr", "edit", "series"]); + + assert!(result.is_err()); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_edit_series_with_series_id_still_requires_arguments() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "sonarr", + "edit", + "series", + "--series-id", + "1", + ]); + + assert!(result.is_err()); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_edit_series_monitoring_flags_conflict() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "sonarr", + "edit", + "series", + "--series-id", + "1", + "--enable-monitoring", + "--disable-monitoring", + ]); + + assert!(result.is_err()); + assert_eq!(result.unwrap_err().kind(), ErrorKind::ArgumentConflict); + } + + #[test] + fn test_edit_series_season_folders_flags_conflict() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "sonarr", + "edit", + "series", + "--series-id", + "1", + "--enable-season-folders", + "--disable-season-folders", + ]); + + assert!(result.is_err()); + assert_eq!(result.unwrap_err().kind(), ErrorKind::ArgumentConflict); + } + + #[test] + fn test_edit_series_tag_flags_conflict() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "sonarr", + "edit", + "series", + "--series-id", + "1", + "--tag", + "1", + "--clear-tags", + ]); + + assert!(result.is_err()); + assert_eq!(result.unwrap_err().kind(), ErrorKind::ArgumentConflict); + } + + #[rstest] + fn test_edit_series_assert_argument_flags_require_args( + #[values( + "--series-type", + "--quality-profile-id", + "--language-profile-id", + "--root-folder-path", + "--tag" + )] + flag: &str, + ) { + let result = Cli::command().try_get_matches_from([ + "managarr", + "sonarr", + "edit", + "series", + "--series-id", + "1", + flag, + ]); + + assert!(result.is_err()); + assert_eq!(result.unwrap_err().kind(), ErrorKind::InvalidValue); + } + + #[test] + fn test_edit_series_series_type_validation() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "sonarr", + "edit", + "series", + "--series-id", + "1", + "--series-type", + "test", + ]); + + assert!(result.is_err()); + assert_eq!(result.unwrap_err().kind(), ErrorKind::InvalidValue); + } + + #[test] + fn test_edit_series_only_requires_at_least_one_argument_plus_series_id() { + let expected_args = SonarrEditCommand::Series { + series_id: 1, + enable_monitoring: false, + disable_monitoring: false, + enable_season_folders: false, + disable_season_folders: false, + series_type: None, + quality_profile_id: None, + language_profile_id: None, + root_folder_path: Some("/nfs/test".to_owned()), + tag: None, + clear_tags: false, + }; + + let result = Cli::try_parse_from([ + "managarr", + "sonarr", + "edit", + "series", + "--series-id", + "1", + "--root-folder-path", + "/nfs/test", + ]); + + assert!(result.is_ok()); + + if let Some(Command::Sonarr(SonarrCommand::Edit(edit_command))) = result.unwrap().command { + assert_eq!(edit_command, expected_args); + } + } + + #[test] + fn test_edit_series_tag_argument_is_repeatable() { + let expected_args = SonarrEditCommand::Series { + series_id: 1, + enable_monitoring: false, + disable_monitoring: false, + enable_season_folders: false, + disable_season_folders: false, + series_type: None, + quality_profile_id: None, + language_profile_id: None, + root_folder_path: None, + tag: Some(vec![1, 2]), + clear_tags: false, + }; + + let result = Cli::try_parse_from([ + "managarr", + "sonarr", + "edit", + "series", + "--series-id", + "1", + "--tag", + "1", + "--tag", + "2", + ]); + + assert!(result.is_ok()); + + if let Some(Command::Sonarr(SonarrCommand::Edit(edit_command))) = result.unwrap().command { + assert_eq!(edit_command, expected_args); + } + } + + #[test] + fn test_edit_series_all_arguments_defined() { + let expected_args = SonarrEditCommand::Series { + series_id: 1, + enable_monitoring: true, + disable_monitoring: false, + enable_season_folders: true, + disable_season_folders: false, + series_type: Some(SeriesType::Anime), + quality_profile_id: Some(1), + language_profile_id: Some(1), + root_folder_path: Some("/nfs/test".to_owned()), + tag: Some(vec![1, 2]), + clear_tags: false, + }; + + let result = Cli::try_parse_from([ + "managarr", + "sonarr", + "edit", + "series", + "--series-id", + "1", + "--enable-monitoring", + "--enable-season-folders", + "--series-type", + "anime", + "--quality-profile-id", + "1", + "--language-profile-id", + "1", + "--root-folder-path", + "/nfs/test", + "--tag", + "1", + "--tag", + "2", + ]); + + assert!(result.is_ok()); + + if let Some(Command::Sonarr(SonarrCommand::Edit(edit_command))) = result.unwrap().command { + assert_eq!(edit_command, expected_args); + } + } } mod handler { @@ -375,7 +614,7 @@ mod tests { }, models::{ servarr_models::EditIndexerParams, - sonarr_models::{IndexerSettings, SonarrSerdeable}, + sonarr_models::{EditSeriesParams, IndexerSettings, SeriesType, SonarrSerdeable}, Serdeable, }, network::{sonarr_network::SonarrEvent, MockNetworkTrait, NetworkEvent}, @@ -490,5 +729,146 @@ mod tests { assert!(result.is_ok()); } + + #[tokio::test] + async fn test_handle_edit_series_command() { + let expected_edit_series_params = EditSeriesParams { + series_id: 1, + monitored: Some(true), + use_season_folders: Some(true), + series_type: Some(SeriesType::Anime), + quality_profile_id: Some(1), + language_profile_id: Some(1), + root_folder_path: Some("/nfs/test".to_owned()), + tags: Some(vec![1, 2]), + clear_tags: false, + }; + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::( + SonarrEvent::EditSeries(Some(expected_edit_series_params)).into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Sonarr(SonarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::default())); + let edit_series_command = SonarrEditCommand::Series { + series_id: 1, + enable_monitoring: true, + disable_monitoring: false, + enable_season_folders: true, + disable_season_folders: false, + series_type: Some(SeriesType::Anime), + quality_profile_id: Some(1), + language_profile_id: Some(1), + root_folder_path: Some("/nfs/test".to_owned()), + tag: Some(vec![1, 2]), + clear_tags: false, + }; + + let result = SonarrEditCommandHandler::with(&app_arc, edit_series_command, &mut mock_network) + .handle() + .await; + + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_handle_edit_series_command_handles_disable_monitoring_flag_properly() { + let expected_edit_series_params = EditSeriesParams { + series_id: 1, + monitored: Some(false), + use_season_folders: Some(false), + series_type: Some(SeriesType::Anime), + quality_profile_id: Some(1), + language_profile_id: Some(1), + root_folder_path: Some("/nfs/test".to_owned()), + tags: Some(vec![1, 2]), + clear_tags: false, + }; + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::( + SonarrEvent::EditSeries(Some(expected_edit_series_params)).into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Sonarr(SonarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::default())); + let edit_series_command = SonarrEditCommand::Series { + series_id: 1, + enable_monitoring: false, + disable_monitoring: true, + enable_season_folders: false, + disable_season_folders: true, + series_type: Some(SeriesType::Anime), + quality_profile_id: Some(1), + language_profile_id: Some(1), + root_folder_path: Some("/nfs/test".to_owned()), + tag: Some(vec![1, 2]), + clear_tags: false, + }; + + let result = SonarrEditCommandHandler::with(&app_arc, edit_series_command, &mut mock_network) + .handle() + .await; + + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_handle_edit_series_command_no_monitoring_boolean_flags_returns_none_value() { + let expected_edit_series_params = EditSeriesParams { + series_id: 1, + monitored: None, + use_season_folders: None, + series_type: Some(SeriesType::Anime), + quality_profile_id: Some(1), + language_profile_id: Some(1), + root_folder_path: Some("/nfs/test".to_owned()), + tags: Some(vec![1, 2]), + clear_tags: false, + }; + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::( + SonarrEvent::EditSeries(Some(expected_edit_series_params)).into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Sonarr(SonarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::default())); + let edit_series_command = SonarrEditCommand::Series { + series_id: 1, + enable_monitoring: false, + disable_monitoring: false, + enable_season_folders: false, + disable_season_folders: false, + series_type: Some(SeriesType::Anime), + quality_profile_id: Some(1), + language_profile_id: Some(1), + root_folder_path: Some("/nfs/test".to_owned()), + tag: Some(vec![1, 2]), + clear_tags: false, + }; + + let result = SonarrEditCommandHandler::with(&app_arc, edit_series_command, &mut mock_network) + .handle() + .await; + + assert!(result.is_ok()); + } } } From c7a0e3348514b84a2eb464106b4bb40ff35e9955 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Mon, 25 Nov 2024 16:28:21 -0700 Subject: [PATCH 114/119] fix(network): Force sonarr to save edits to indexers --- src/network/sonarr_network.rs | 2 +- src/network/sonarr_network_tests.rs | 36 ++++++++++++++++++++++++----- 2 files changed, 31 insertions(+), 7 deletions(-) diff --git a/src/network/sonarr_network.rs b/src/network/sonarr_network.rs index 02397fe..6a0d84a 100644 --- a/src/network/sonarr_network.rs +++ b/src/network/sonarr_network.rs @@ -1060,7 +1060,7 @@ impl<'a, 'b> Network<'a, 'b> { RequestMethod::Put, Some(detailed_indexer_body), Some(format!("/{id}")), - None, + Some("forceSave=true".to_owned()), ) .await; diff --git a/src/network/sonarr_network_tests.rs b/src/network/sonarr_network_tests.rs index 3a3acd8..3b5e058 100644 --- a/src/network/sonarr_network_tests.rs +++ b/src/network/sonarr_network_tests.rs @@ -1194,7 +1194,11 @@ mod test { let async_edit_server = server .mock( "PUT", - format!("/api/v3{}/1", SonarrEvent::EditIndexer(None).resource()).as_str(), + format!( + "/api/v3{}/1?forceSave=true", + SonarrEvent::EditIndexer(None).resource() + ) + .as_str(), ) .with_status(202) .match_header("X-Api-Key", "test1234") @@ -1287,7 +1291,11 @@ mod test { let async_edit_server = server .mock( "PUT", - format!("/api/v3{}/1", SonarrEvent::EditIndexer(None).resource()).as_str(), + format!( + "/api/v3{}/1?forceSave=true", + SonarrEvent::EditIndexer(None).resource() + ) + .as_str(), ) .with_status(202) .match_header("X-Api-Key", "test1234") @@ -1396,7 +1404,11 @@ mod test { let async_edit_server = server .mock( "PUT", - format!("/api/v3{}/1", SonarrEvent::EditIndexer(None).resource()).as_str(), + format!( + "/api/v3{}/1?forceSave=true", + SonarrEvent::EditIndexer(None).resource() + ) + .as_str(), ) .with_status(202) .match_header("X-Api-Key", "test1234") @@ -1525,7 +1537,11 @@ mod test { let async_edit_server = server .mock( "PUT", - format!("/api/v3{}/1", SonarrEvent::EditIndexer(None).resource()).as_str(), + format!( + "/api/v3{}/1?forceSave=true", + SonarrEvent::EditIndexer(None).resource() + ) + .as_str(), ) .with_status(202) .match_header("X-Api-Key", "test1234") @@ -1587,7 +1603,11 @@ mod test { let async_edit_server = server .mock( "PUT", - format!("/api/v3{}/1", SonarrEvent::EditIndexer(None).resource()).as_str(), + format!( + "/api/v3{}/1?forceSave=true", + SonarrEvent::EditIndexer(None).resource() + ) + .as_str(), ) .with_status(202) .match_header("X-Api-Key", "test1234") @@ -1673,7 +1693,11 @@ mod test { let async_edit_server = server .mock( "PUT", - format!("/api/v3{}/1", SonarrEvent::EditIndexer(None).resource()).as_str(), + format!( + "/api/v3{}/1?forceSave=true", + SonarrEvent::EditIndexer(None).resource() + ) + .as_str(), ) .with_status(202) .match_header("X-Api-Key", "test1234") From ad0b3989edbb206d60c0a76126119478ea79ce2a Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Mon, 25 Nov 2024 16:43:29 -0700 Subject: [PATCH 115/119] fix(cli): Corrected some copy/paste typos --- src/cli/sonarr/list_command_handler_tests.rs | 4 ++-- src/cli/sonarr/mod.rs | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/cli/sonarr/list_command_handler_tests.rs b/src/cli/sonarr/list_command_handler_tests.rs index c6c8f04..7e71599 100644 --- a/src/cli/sonarr/list_command_handler_tests.rs +++ b/src/cli/sonarr/list_command_handler_tests.rs @@ -193,10 +193,10 @@ mod tests { use crate::cli::sonarr::list_command_handler::{SonarrListCommand, SonarrListCommandHandler}; use crate::cli::CliCommandHandler; use crate::models::sonarr_models::SonarrSerdeable; + use crate::models::Serdeable; use crate::network::sonarr_network::SonarrEvent; use crate::{ app::App, - models::{radarr_models::RadarrSerdeable, Serdeable}, network::{MockNetworkTrait, NetworkEvent}, }; @@ -224,7 +224,7 @@ mod tests { .with(eq::(expected_sonarr_event.into())) .times(1) .returning(|_| { - Ok(Serdeable::Radarr(RadarrSerdeable::Value( + Ok(Serdeable::Sonarr(SonarrSerdeable::Value( json!({"testResponse": "response"}), ))) }); diff --git a/src/cli/sonarr/mod.rs b/src/cli/sonarr/mod.rs index 1cf214d..1cb1e42 100644 --- a/src/cli/sonarr/mod.rs +++ b/src/cli/sonarr/mod.rs @@ -118,7 +118,7 @@ pub enum SonarrCommand { #[arg(long, help = "The ID of the indexer to test", required = true)] indexer_id: i64, }, - #[command(about = "Test all Radarr indexers")] + #[command(about = "Test all Sonarr indexers")] TestAllIndexers, } From 80787d11875e318b71ca4de396be6f33ba9c9227 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Mon, 25 Nov 2024 16:50:28 -0700 Subject: [PATCH 116/119] style(lint): Added allow dead code directives around certain structs that are causing linter complaints because these will either be used once sonarr UI work begins, or in future Servarr developments that will make life easier --- src/models/servarr_data/sonarr/modals.rs | 3 +++ src/models/stateful_tree.rs | 6 ++++++ 2 files changed, 9 insertions(+) diff --git a/src/models/servarr_data/sonarr/modals.rs b/src/models/servarr_data/sonarr/modals.rs index d8b53d5..c41b58b 100644 --- a/src/models/servarr_data/sonarr/modals.rs +++ b/src/models/servarr_data/sonarr/modals.rs @@ -248,6 +248,9 @@ impl From<&SonarrData> for EditSeriesModal { #[derive(Default)] pub struct EpisodeDetailsModal { + // Temporarily allowing this, since the value is only current written and not read. + // This will be read from once I begin the UI work for Sonarr + #[allow(dead_code)] pub episode_details: ScrollableText, pub file_details: String, pub audio_details: String, diff --git a/src/models/stateful_tree.rs b/src/models/stateful_tree.rs index e33b516..bc1904a 100644 --- a/src/models/stateful_tree.rs +++ b/src/models/stateful_tree.rs @@ -15,9 +15,15 @@ where T: ToText + Hash + Clone + PartialEq + Eq + Debug + Default + Display + PartialEq + Eq, { pub state: TreeState, + // Allowing the existence of this struct for now, since it may become useful + // for future UI developments with additional Servarrs + #[allow(dead_code)] pub items: Vec>, } +// Allowing the existence of this struct for now, since it may become useful +// for future UI developments with additional Servarrs +#[allow(dead_code)] impl StatefulTree where T: ToText + Hash + Clone + PartialEq + Eq + Debug + Default + Display + PartialEq + Eq, From 3ef5c1911d37644bc1c4f3a02218fcbfe56385cf Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Mon, 25 Nov 2024 16:52:08 -0700 Subject: [PATCH 117/119] docs(README): Updated the README to include new features that are available in the Sonarr CLI release --- README.md | 80 ++++++++++++++++++++++++++++++++++++------------------- 1 file changed, 52 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index ec4cafb..dd5b412 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,7 @@ Key: |--------------------|-----------| | :white_check_mark: | Supported | | :x: | Missing | +| :clock3: | Planned | | :no_entry_sign: | Won't Add | ### Radarr @@ -84,7 +85,25 @@ Key: | :white_check_mark: | :white_check_mark: | Manually trigger scheduled tasks | ### Sonarr -- [ ] Support for Sonarr + +| TUI | CLI | Feature | +|----------|--------------------|--------------------------------------------------------------------------------------------------------------------| +| :clock3: | :white_check_mark: | View your library, downloads, blocklist, episodes | +| :clock3: | :white_check_mark: | View details of a specific series, or episode including description, history, downloaded file info, or the credits | +| :clock3: | :white_check_mark: | View your host and security configs from the CLI to programmatically fetch the API token, among other settings | +| :clock3: | :white_check_mark: | Search your library | +| :clock3: | :white_check_mark: | Add series to your library | +| :clock3: | :white_check_mark: | Delete series, downloads, indexers, root folders, and episode files | +| :clock3: | :white_check_mark: | Mark history events as failed | +| :clock3: | :white_check_mark: | Trigger automatic searches for series, seasons, or episodes | +| :clock3: | :white_check_mark: | Trigger refresh and disk scan for series and downloads | +| :clock3: | :white_check_mark: | Manually search for series, seasons, or episodes | +| :clock3: | :white_check_mark: | Edit your series and indexers | +| :clock3: | :white_check_mark: | Manage your tags | +| :clock3: | :white_check_mark: | Manage your root folders | +| :clock3: | :white_check_mark: | Manage your blocklist | +| :clock3: | :white_check_mark: | View and browse logs, tasks, events queues, and updates | +| :clock3: | :white_check_mark: | Manually trigger scheduled tasks | ### Readarr @@ -116,13 +135,13 @@ Managarr can be used in one of two ways: As a TUI, or as a CLI for managing your All management features available in the TUI are also available in the CLI. However, the CLI is equipped with additional features to allow for more advanced usage and automation. -The CLI can be helpful for automating tasks or for use in scripts. For example, you can use the CLI to trigger a search for a movie, or to add a movie to your library. +The CLI can be helpful for automating tasks or for use in scripts. For example, you can use the CLI to trigger a search for a movie, or to add a movie to your Radarr library. To see all available commands, simply run `managarr --help`: ```shell $ managarr --help -managarr 0.2.1 +managarr 0.3.0 Alex Clarke A TUI and CLI to manage your Servarrs @@ -131,43 +150,48 @@ Usage: managarr [OPTIONS] [COMMAND] Commands: radarr Commands for manging your Radarr instance + sonarr Commands for manging your Sonarr instance completions Generate shell completions for the Managarr CLI + tail-logs Tail Managarr logs help Print this message or the help of the given subcommand(s) Options: - --config The Managarr configuration file to use - -h, --help Print help - -V, --version Print version + --disable-spinner Disable the spinner (can sometimes make parsing output challenging) [env: MANAGARR_DISABLE_SPINNER=] + --config The Managarr configuration file to use [env: MANAGARR_CONFIG_FILE=] + -h, --help Print help + -V, --version Print version ``` -All subcommands also have detailed help menus to show you how to use them. For example, to see all available commands for Radarr, you would run: +All subcommands also have detailed help menus to show you how to use them. For example, to see all available commands for Sonarr, you would run: ```shell -$ managarr radarr --help -Commands for manging your Radarr instance +$ managarr sonarr --help +Commands for manging your Sonarr instance -Usage: managarr radarr [OPTIONS] +Usage: managarr sonarr [OPTIONS] Commands: - add Commands to add or create new resources within your Radarr instance - delete Commands to delete resources from your Radarr instance - edit Commands to edit resources in your Radarr instance - get Commands to fetch details of the resources in your Radarr instance - list Commands to list attributes from your Radarr instance - refresh Commands to refresh the data in your Radarr instance - clear-blocklist Clear the blocklist - download-release Manually download the given release for the specified movie ID - manual-search Trigger a manual search of releases for the movie with the given ID - search-new-movie Search for a new film to add to Radarr - start-task Start the specified Radarr task - test-indexer Test the indexer with the given ID. Note that a successful test returns an empty JSON body; i.e. '{}' - test-all-indexers Test all indexers - trigger-automatic-search Trigger an automatic search for the movie with the specified ID - help Print this message or the help of the given subcommand(s) + add Commands to add or create new resources within your Sonarr instance + delete Commands to delete resources from your Sonarr instance + edit Commands to edit resources in your Sonarr instance + get Commands to fetch details of the resources in your Sonarr instance + download Commands to download releases in your Sonarr instance + list Commands to list attributes from your Sonarr instance + refresh Commands to refresh the data in your Sonarr instance + manual-search Commands to manually search for releases + trigger-automatic-search Commands to trigger automatic searches for releases of different resources in your Sonarr instance + clear-blocklist Clear the blocklist + mark-history-item-as-failed Mark the Sonarr history item with the given ID as 'failed' + search-new-series Search for a new series to add to Sonarr + start-task Start the specified Sonarr task + test-indexer Test the indexer with the given ID. Note that a successful test returns an empty JSON body; i.e. '{}' + test-all-indexers Test all Sonarr indexers + help Print this message or the help of the given subcommand(s) Options: - --config The Managarr configuration file to use - -h, --help Print help + --disable-spinner Disable the spinner (can sometimes make parsing output challenging) [env: MANAGARR_DISABLE_SPINNER=] + --config The Managarr configuration file to use [env: MANAGARR_CONFIG_FILE=] + -h, --help Print help ``` **Pro Tip:** The CLI is even more powerful and useful when used in conjunction with the `jq` CLI tool. This allows you to parse the JSON response from the Managarr CLI and use it in your scripts; For example, to extract the `movieId` of the movie "Ad Astra", you would run: @@ -181,7 +205,7 @@ $ managarr radarr list movies | jq '.[] | select(.title == "Ad Astra") | .id' Managarr assumes reasonable defaults to connect to each service (i.e. Radarr is on localhost:7878), but all servers will require you to input the API token. -The configuration file is located somewhere different for each OS +The configuration file is located somewhere different for each OS. ### Linux ``` From 866b0c753761d7287cc6b4f2d6380b8aa4c592cc Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Mon, 25 Nov 2024 16:55:14 -0700 Subject: [PATCH 118/119] fix(lint): Addressed linter complaints --- src/models/stateful_tree_tests.rs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/models/stateful_tree_tests.rs b/src/models/stateful_tree_tests.rs index cd63da4..dcedf07 100644 --- a/src/models/stateful_tree_tests.rs +++ b/src/models/stateful_tree_tests.rs @@ -3,12 +3,12 @@ mod tests { use std::hash::{DefaultHasher, Hash, Hasher}; use crate::models::stateful_tree::StatefulTree; + use crate::models::Scrollable; use managarr_tree_widget::{Tree, TreeItem, TreeState}; use pretty_assertions::{assert_eq, assert_str_eq}; use ratatui::buffer::Buffer; use ratatui::layout::Rect; use ratatui::widgets::StatefulWidget; - use crate::models::Scrollable; #[test] fn test_stateful_tree_scrolling_on_empty_tree_performs_no_op() { @@ -106,21 +106,21 @@ mod tests { render(&mut stateful_tree.state, &stateful_tree.items); stateful_tree.state.key_down(); render(&mut stateful_tree.state, &stateful_tree.items); - + assert_eq!(stateful_tree.state.selected(), &[hash("Test 1")]); stateful_tree.state.key_down(); render(&mut stateful_tree.state, &stateful_tree.items); stateful_tree.set_items(items_vec.clone()); render(&mut stateful_tree.state, &stateful_tree.items); - + assert_eq!(stateful_tree.state.selected(), &[hash("Test 2")]); stateful_tree.state.key_down(); render(&mut stateful_tree.state, &stateful_tree.items); stateful_tree.set_items(items_vec); render(&mut stateful_tree.state, &stateful_tree.items); - + assert_eq!(stateful_tree.state.selected(), &[hash("Test 3")]); } @@ -130,7 +130,7 @@ mod tests { render(&mut stateful_tree.state, &stateful_tree.items); stateful_tree.state.key_down(); render(&mut stateful_tree.state, &stateful_tree.items); - + let current_selection = stateful_tree.current_selection(); assert!(current_selection.is_some()); From eb856e28d7d2b07b0291cad938ebf32cb0e9060e Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Mon, 25 Nov 2024 16:58:15 -0700 Subject: [PATCH 119/119] fix(minimal-versions): Addressed concerns with the minimal-versions CI checks --- Cargo.lock | 484 ++++++++++++++++++++++++++++++++--------------------- Cargo.toml | 2 +- 2 files changed, 293 insertions(+), 193 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ff134d6..7d69414 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -98,9 +98,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.93" +version = "1.0.68" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c95c10ba0b00a02636238b814946408b1322d5ac4760326e6fb8ec956d85775" +checksum = "2cb2f989d18dd141ab8ae82f64d1a8cdd37e0840f73a406896cf5e99502fab61" [[package]] name = "arc-swap" @@ -142,7 +142,7 @@ checksum = "721cae7de5c34fbb2acd27e21e6d2cf7b886dce0c27388d46c4e6c47ea4318dd" dependencies = [ "proc-macro2", "quote", - "syn 2.0.87", + "syn 2.0.89", ] [[package]] @@ -195,9 +195,9 @@ checksum = "b048fb63fd8b5923fc5aa7b340d8e156aec7ec02f0c78fa8a6ddc2613f6f71de" [[package]] name = "bstr" -version = "1.10.0" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40723b8fb387abc38f4f4a37c09073622e41dd12327033091ef8950659e6dc0c" +checksum = "1a68f1f47cdf0ec8ee4b941b2eee2a80cb796db73118c0dd09ac63fbe405be22" dependencies = [ "memchr", "regex-automata", @@ -224,9 +224,9 @@ checksum = "9ac0150caa2ae65ca5bd83f25c7de183dea78d4d366469f148435e2acfbad0da" [[package]] name = "cargo-husky" -version = "1.5.0" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b02b629252fe8ef6460461409564e2c21d0c8e77e0944f3d189ff06c4e932ad" +checksum = "fa108bb6da8de0669ab0fef3a4afabcc3446938b09b1ffe2e90486c75df8f215" [[package]] name = "cassowary" @@ -245,9 +245,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.0" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1aeb932158bd710538c73702db6945cb68a8fb08c519e6e12706b94263b36db8" +checksum = "fd9de9f2205d5ef3fd67e685b0df337994ddd4495e2a28d185500d0e1edfea47" dependencies = [ "shlex", ] @@ -281,9 +281,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.5.21" +version = "4.5.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fb3b4b9e5a7c7514dfa52869339ee98b3156b0bfb4e8a77c4ff4babb64b1604f" +checksum = "b97f376d85a664d5837dbae44bf546e6477a679ff6610010f17276f686d867e8" dependencies = [ "clap_builder", "clap_derive", @@ -291,9 +291,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.21" +version = "4.5.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b17a95aa67cc7b5ebd32aa5370189aa0d79069ef1c64ce893bd30fb24bff20ec" +checksum = "19bc80abd44e4bed93ca373a0704ccbd1b710dc5749406201bb018272808dc54" dependencies = [ "anstream", "anstyle", @@ -303,9 +303,9 @@ dependencies = [ [[package]] name = "clap_complete" -version = "4.5.38" +version = "4.5.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9647a559c112175f17cf724dc72d3645680a883c58481332779192b0d8e7a01" +checksum = "9646e2e245bf62f45d39a0f3f36f1171ad1ea0d6967fd114bca72cb02a8fcdfb" dependencies = [ "clap", ] @@ -319,7 +319,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.87", + "syn 2.0.89", ] [[package]] @@ -360,13 +360,13 @@ dependencies = [ [[package]] name = "confy" -version = "0.6.1" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "45b1f4c00870f07dc34adcac82bb6a72cc5aabca8536ba1797e01df51d2ce9a0" +checksum = "15d296c475c6ed4093824c28e222420831d27577aaaf0a1163a3b7fc35b248a5" dependencies = [ "directories", "serde", - "serde_yaml", + "serde_yaml 0.9.16", "thiserror", ] @@ -407,7 +407,7 @@ checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" dependencies = [ "bitflags", "crossterm_winapi", - "mio", + "mio 1.0.2", "parking_lot", "rustix", "signal-hook", @@ -424,6 +424,16 @@ dependencies = [ "winapi", ] +[[package]] +name = "ctor" +version = "0.1.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d2301688392eb071b0bf1a37be05c469d3cc4dbbd95df672fe28ab021e6a096" +dependencies = [ + "quote", + "syn 1.0.109", +] + [[package]] name = "ctrlc" version = "3.4.5" @@ -434,41 +444,6 @@ dependencies = [ "windows-sys 0.59.0", ] -[[package]] -name = "darling" -version = "0.20.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f63b86c8a8826a49b8c21f08a2d07338eec8d900540f8630dc76284be802989" -dependencies = [ - "darling_core", - "darling_macro", -] - -[[package]] -name = "darling_core" -version = "0.20.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95133861a8032aaea082871032f5815eb9e98cef03fa916ab4500513994df9e5" -dependencies = [ - "fnv", - "ident_case", - "proc-macro2", - "quote", - "strsim", - "syn 2.0.87", -] - -[[package]] -name = "darling_macro" -version = "0.20.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d336a2a514f6ccccaa3e09b02d41d35330c07ddf03a62165fcec10bb561c7806" -dependencies = [ - "darling_core", - "quote", - "syn 2.0.87", -] - [[package]] name = "deranged" version = "0.3.11" @@ -557,7 +532,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn 2.0.87", + "syn 2.0.89", ] [[package]] @@ -713,7 +688,7 @@ checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" dependencies = [ "proc-macro2", "quote", - "syn 2.0.87", + "syn 2.0.89", ] [[package]] @@ -777,17 +752,36 @@ checksum = "d2fabcfbdc87f4758337ca535fb41a6d701b65693ce38287d856d1674551ec9b" [[package]] name = "h2" -version = "0.4.6" +version = "0.3.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "524e8ac6999421f49a846c2d4411f337e53497d8ec55d67753beffa43c5d9205" +checksum = "81fe527a889e1532da5c525686d96d4c2e74cdd345badf8dfef9f6b39dd5f5e8" +dependencies = [ + "bytes", + "fnv", + "futures-core", + "futures-sink", + "futures-util", + "http 0.2.12", + "indexmap 2.6.0", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "h2" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccae279728d634d083c00f6099cb58f01cc99c145b84b8be2f6c74618d79922e" dependencies = [ "atomic-waker", "bytes", "fnv", "futures-core", "futures-sink", - "http", - "indexmap", + "http 1.1.0", + "indexmap 2.6.0", "slab", "tokio", "tokio-util", @@ -796,9 +790,15 @@ dependencies = [ [[package]] name = "hashbrown" -version = "0.15.1" +version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a9bfc1af68b1726ea47d3d5109de126281def866b33970e10fbab11b5dafab3" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + +[[package]] +name = "hashbrown" +version = "0.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf151400ff0baff5465007dd2f3e717f3fe502074ca563069ce3a6629d07b289" dependencies = [ "allocator-api2", "equivalent", @@ -817,6 +817,17 @@ version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024" +[[package]] +name = "http" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + [[package]] name = "http" version = "1.1.0" @@ -828,6 +839,17 @@ dependencies = [ "itoa", ] +[[package]] +name = "http-body" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" +dependencies = [ + "bytes", + "http 0.2.12", + "pin-project-lite", +] + [[package]] name = "http-body" version = "1.0.1" @@ -835,7 +857,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" dependencies = [ "bytes", - "http", + "http 1.1.0", ] [[package]] @@ -846,8 +868,8 @@ checksum = "793429d76616a256bcb62c2a2ec2bed781c8307e797e2598c50010f2bee2544f" dependencies = [ "bytes", "futures-util", - "http", - "http-body", + "http 1.1.0", + "http-body 1.0.1", "pin-project-lite", ] @@ -887,18 +909,41 @@ checksum = "9a3a5bfb195931eeb336b2a7b4d761daec841b97f947d34394601737a7bba5e4" [[package]] name = "hyper" -version = "1.5.0" +version = "0.14.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbbff0a806a4728c99295b254c8838933b5b082d75e3cb70c8dab21fdfbcfa9a" +checksum = "8c08302e8fa335b151b788c775ff56e7a03ae64ff85c548ee820fecb70356e85" +dependencies = [ + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "h2 0.3.26", + "http 0.2.12", + "http-body 0.4.6", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", + "want", +] + +[[package]] +name = "hyper" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97818827ef4f364230e16705d4706e2897df2bb60617d6ca15d598025a3c481f" dependencies = [ "bytes", "futures-channel", "futures-util", - "h2", - "http", - "http-body", + "h2 0.4.7", + "http 1.1.0", + "http-body 1.0.1", "httparse", - "httpdate", "itoa", "pin-project-lite", "smallvec", @@ -913,8 +958,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08afdbb5c31130e3034af566421053ab03787c640246a446327f550d11bcb333" dependencies = [ "futures-util", - "http", - "hyper", + "http 1.1.0", + "hyper 1.5.1", "hyper-util", "rustls", "rustls-pki-types", @@ -931,7 +976,7 @@ checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" dependencies = [ "bytes", "http-body-util", - "hyper", + "hyper 1.5.1", "hyper-util", "native-tls", "tokio", @@ -948,9 +993,9 @@ dependencies = [ "bytes", "futures-channel", "futures-util", - "http", - "http-body", - "hyper", + "http 1.1.0", + "http-body 1.0.1", + "hyper 1.5.1", "pin-project-lite", "socket2", "tokio", @@ -1096,15 +1141,9 @@ checksum = "1ec89e9337638ecdc08744df490b221a7399bf8d164eb52a665454e60e075ad6" dependencies = [ "proc-macro2", "quote", - "syn 2.0.87", + "syn 2.0.89", ] -[[package]] -name = "ident_case" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" - [[package]] name = "idna" version = "1.0.3" @@ -1126,6 +1165,16 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", +] + [[package]] name = "indexmap" version = "2.6.0" @@ -1133,7 +1182,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "707907fe3c25f5424cce2cb7e1cbcafee6bdbe735ca90ef77c29e84591e5b9da" dependencies = [ "equivalent", - "hashbrown", + "hashbrown 0.15.2", ] [[package]] @@ -1151,22 +1200,18 @@ dependencies = [ [[package]] name = "indoc" -version = "2.0.5" +version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b248f5224d1d606005e02c97f5aa4e88eeb230488bcc03bc9ca4d7991399f2b5" +checksum = "6fe2b9d82064e8a0226fddb3547f37f28eaa46d0fc210e275d835f08cf3b76a7" [[package]] name = "instability" -version = "0.3.3" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b829f37dead9dc39df40c2d3376c179fdfd2ac771f53f55d3c30dc096a3c0c6e" +checksum = "b23a0c8dfe501baac4adf6ebbfa6eddf8f0c07f56b058cc1288017e32397846c" dependencies = [ - "darling", - "indoc", - "pretty_assertions", - "proc-macro2", "quote", - "syn 2.0.87", + "syn 2.0.89", ] [[package]] @@ -1192,9 +1237,9 @@ dependencies = [ [[package]] name = "itoa" -version = "1.0.11" +version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49f1f14873335454500d59611f1cf4a4b0f786f9ac11f4312a78e4cf2566695b" +checksum = "d75a2a4b1b190afb6f5425f10f6a8f959d2ea0b9c2b1d79553551850539e4674" [[package]] name = "js-sys" @@ -1213,9 +1258,9 @@ checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" [[package]] name = "libc" -version = "0.2.162" +version = "0.2.165" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "18d287de67fe55fd7e1581fe933d965a5a9477b38e949cfa9f8574ef01506398" +checksum = "fcb4d3d38eab6c5239a362fa8bae48c03baf980a6e7079f063942d563ef3533e" [[package]] name = "libredox" @@ -1227,6 +1272,12 @@ dependencies = [ "libc", ] +[[package]] +name = "linked-hash-map" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" + [[package]] name = "linux-raw-sys" version = "0.4.14" @@ -1235,9 +1286,9 @@ checksum = "78b3ae25bc7c8c38cec158d1f2757ee79e9b3740fbc7ccf0e59e4b08d793fa89" [[package]] name = "litemap" -version = "0.7.3" +version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "643cb0b8d4fcc284004d5fd0d67ccf61dfffadb7f75e1e71bc420f4688a3a704" +checksum = "4ee93343901ab17bd981295f2cf0026d4ad018c7c31ba84549a4ddbb47a45104" [[package]] name = "lock_api" @@ -1251,10 +1302,11 @@ dependencies = [ [[package]] name = "log" -version = "0.4.22" +version = "0.4.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7a70ba024b9dc04c27ea2f0c0548feb474ec5c54bba33a7f72f873a39d07b24" +checksum = "abb12e687cfb44aa40f41fc3978ef76448f9b6038cad6aef4259d3c095a2382e" dependencies = [ + "cfg-if", "serde", ] @@ -1266,9 +1318,9 @@ checksum = "a94d21414c1f4a51209ad204c1776a3d0765002c76c6abcb602a6f09f1e881c7" [[package]] name = "log4rs" -version = "1.3.0" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0816135ae15bd0391cf284eab37e6e3ee0a6ee63d2ceeb659862bd8d0a984ca6" +checksum = "d36ca1786d9e79b8193a68d480a0907b612f109537115c6ff655a3a1967533fd" dependencies = [ "anyhow", "arc-swap", @@ -1279,13 +1331,11 @@ dependencies = [ "libc", "log", "log-mdc", - "once_cell", "parking_lot", - "rand", "serde", "serde-value", "serde_json", - "serde_yaml", + "serde_yaml 0.8.26", "thiserror", "thread-id", "typemap-ors", @@ -1298,7 +1348,7 @@ version = "0.12.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" dependencies = [ - "hashbrown", + "hashbrown 0.15.2", ] [[package]] @@ -1336,7 +1386,7 @@ dependencies = [ "rstest", "serde", "serde_json", - "serde_yaml", + "serde_yaml 0.9.16", "strum", "strum_macros", "tokio", @@ -1375,6 +1425,17 @@ dependencies = [ "adler2", ] +[[package]] +name = "mio" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.48.0", +] + [[package]] name = "mio" version = "1.0.2" @@ -1411,24 +1472,20 @@ dependencies = [ "cfg-if", "proc-macro2", "quote", - "syn 2.0.87", + "syn 2.0.89", ] [[package]] name = "mockito" -version = "1.6.1" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "652cd6d169a36eaf9d1e6bce1a221130439a966d7f27858af66a33a66e9c4ee2" +checksum = "8c1eecc3baf782e3c8d6803cc8780268da1f32df6eb88c016c1d80b0df7944cf" dependencies = [ "assert-json-diff", - "bytes", "colored", - "futures-util", - "http", - "http-body", - "http-body-util", - "hyper", - "hyper-util", + "futures", + "hyper 0.14.31", + "lazy_static", "log", "rand", "regex", @@ -1482,6 +1539,16 @@ dependencies = [ "autocfg", ] +[[package]] +name = "num_cpus" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4161fcb6d602d4d2081af7c3a45852d875a03dd337a6bfdd6e06407b61342a43" +dependencies = [ + "hermit-abi", + "libc", +] + [[package]] name = "num_threads" version = "0.1.7" @@ -1535,7 +1602,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.87", + "syn 2.0.89", ] [[package]] @@ -1582,6 +1649,15 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "output_vt100" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "628223faebab4e3e40667ee0b2336d34a5b960ff60ea743ddfdbcf7770bcfb66" +dependencies = [ + "winapi", +] + [[package]] name = "parking_lot" version = "0.12.3" @@ -1637,9 +1713,9 @@ checksum = "953ec861398dccce10c670dfeaf3ec4911ca479e9c02154b3a215178c5f566f2" [[package]] name = "portable-atomic" -version = "1.9.0" +version = "1.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc9c68a3f6da06753e9335d63e27f6b9754dd1920d941135b7ea8224f141adb2" +checksum = "280dc24453071f1b63954171985a0b0d30058d287960968b9b2aca264c8d4ee6" [[package]] name = "powerfmt" @@ -1685,11 +1761,13 @@ dependencies = [ [[package]] name = "pretty_assertions" -version = "1.4.1" +version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ae130e2f271fbc2ac3a40fb1d07180839cdbbe443c7a27e1e3c13c5cac0116d" +checksum = "a25e9bcb20aa780fd0bb16b72403a9064d6b3f22f026946029acb941a50af755" dependencies = [ + "ctor", "diff", + "output_vt100", "yansi", ] @@ -1704,9 +1782,9 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.89" +version = "1.0.92" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f139b0662de085916d1fb67d2b4169d1addddda1919e696f3252b740b629986e" +checksum = "37d3544b3f2748c54e147655edb5025752e2303145b5aefb3c3ea2c78b973bb0" dependencies = [ "unicode-ident", ] @@ -1838,11 +1916,11 @@ dependencies = [ "encoding_rs", "futures-core", "futures-util", - "h2", - "http", - "http-body", + "h2 0.4.7", + "http 1.1.0", + "http-body 1.0.1", "http-body-util", - "hyper", + "hyper 1.5.1", "hyper-rustls", "hyper-tls", "hyper-util", @@ -1911,7 +1989,7 @@ dependencies = [ "regex", "relative-path", "rustc_version", - "syn 2.0.87", + "syn 2.0.89", "unicode-ident", ] @@ -1932,9 +2010,9 @@ dependencies = [ [[package]] name = "rustix" -version = "0.38.40" +version = "0.38.41" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "99e4ea3e1cdc4b559b8e5650f9c8e5998e3e5c1343b4eaf034565f32318d63c0" +checksum = "d7f649912bc1495e167a6edee79151c84b1bad49748cb4f1f1167f459f6224f6" dependencies = [ "bitflags", "errno", @@ -1945,9 +2023,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.16" +version = "0.23.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eee87ff5d9b36712a58574e12e9f0ea80f915a5b0ac518d322b24a465617925e" +checksum = "9c9cc1d47e243d655ace55ed38201c19ae02c148ae56412ab8750e8f0166ab7f" dependencies = [ "once_cell", "rustls-pki-types", @@ -1996,9 +2074,9 @@ checksum = "f3cb5ba0dc43242ce17de99c180e96db90b235b8a9fdc9543c96d2209116bd9f" [[package]] name = "schannel" -version = "0.1.26" +version = "0.1.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "01227be5826fa0690321a2ba6c5cd57a19cf3f6a09e76973b58e61de6ab9d1c1" +checksum = "1f29ebaa345f945cec9fbbc532eb307f0fdad8161f281b6369539c8d84876b3d" dependencies = [ "windows-sys 0.59.0", ] @@ -2040,9 +2118,9 @@ checksum = "61697e0a1c7e512e84a621326239844a24d8207b4669b41bc18b32ea5cbf988b" [[package]] name = "serde" -version = "1.0.215" +version = "1.0.214" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6513c1ad0b11a9376da888e3e0baa0077f1aed55c17f50e7b2397136129fb88f" +checksum = "f55c3193aca71c12ad7890f1785d2b73e1b9f63a0bbc353c08ef26fe03fc56b5" dependencies = [ "serde_derive", ] @@ -2059,23 +2137,22 @@ dependencies = [ [[package]] name = "serde_derive" -version = "1.0.215" +version = "1.0.214" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad1e866f866923f252f05c889987993144fb74e722403468a4ebd70c3cd756c0" +checksum = "de523f781f095e28fa605cdce0f8307e451cc0fd14e2eb4cd2e98a355b147766" dependencies = [ "proc-macro2", "quote", - "syn 2.0.87", + "syn 2.0.89", ] [[package]] name = "serde_json" -version = "1.0.132" +version = "1.0.91" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d726bfaff4b320266d395898905d0eba0345aae23b54aee3a737e260fd46db03" +checksum = "877c235533714907a8c2464236f5c4b2a17262ef1bd71f38f35ea592c8da6883" dependencies = [ "itoa", - "memchr", "ryu", "serde", ] @@ -2103,11 +2180,23 @@ dependencies = [ [[package]] name = "serde_yaml" -version = "0.9.34+deprecated" +version = "0.8.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +checksum = "578a7433b776b56a35785ed5ce9a7e777ac0598aac5a6dd1b4b18a307c7fc71b" dependencies = [ - "indexmap", + "indexmap 1.9.3", + "ryu", + "serde", + "yaml-rust", +] + +[[package]] +name = "serde_yaml" +version = "0.9.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92b5b431e8907b50339b51223b97d102db8d987ced36f6e4d03621db9316c834" +dependencies = [ + "indexmap 1.9.3", "itoa", "ryu", "serde", @@ -2137,7 +2226,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34db1a06d485c9142248b7a054f034b349b212551f3dfd19c94d45a754a217cd" dependencies = [ "libc", - "mio", + "mio 1.0.2", "signal-hook", ] @@ -2224,7 +2313,7 @@ dependencies = [ "proc-macro2", "quote", "rustversion", - "syn 2.0.87", + "syn 2.0.89", ] [[package]] @@ -2246,9 +2335,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.87" +version = "2.0.89" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25aa4ce346d03a6dcd68dd8b4010bcb74e54e62c90c573f394c46eae99aba32d" +checksum = "44d46482f1c1c87acd84dea20c1bf5ebff4c757009ed6bf19cfd36fb10e92c4e" dependencies = [ "proc-macro2", "quote", @@ -2257,9 +2346,9 @@ dependencies = [ [[package]] name = "sync_wrapper" -version = "1.0.1" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7065abeca94b6a8a577f9bd45aa0867a2238b74e8eb67cf10d492bc39351394" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" dependencies = [ "futures-core", ] @@ -2272,7 +2361,7 @@ checksum = "c8af7666ab7b6390ab78131fb5b0fce11d6b7a6951602017c35fa82800708971" dependencies = [ "proc-macro2", "quote", - "syn 2.0.87", + "syn 2.0.89", ] [[package]] @@ -2332,7 +2421,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.87", + "syn 2.0.89", ] [[package]] @@ -2378,31 +2467,32 @@ dependencies = [ [[package]] name = "tokio" -version = "1.41.1" +version = "1.36.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22cfb5bee7a6a52939ca9224d6ac897bb669134078daa8735560897f69de4d33" +checksum = "61285f6515fa018fb2d1e46eb21223fff441ee8db5d0f1435e8ab4f5cdb80931" dependencies = [ "backtrace", "bytes", "libc", - "mio", + "mio 0.8.11", + "num_cpus", "parking_lot", "pin-project-lite", "signal-hook-registry", "socket2", "tokio-macros", - "windows-sys 0.52.0", + "windows-sys 0.48.0", ] [[package]] name = "tokio-macros" -version = "2.4.0" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "693d596312e88961bc67d7f1f97af8a70227d9f90c31bba5806eec004978d752" +checksum = "5b8a1e28f2deaa14e508979454cb3a223b10b938b45af148bc0986de36f1923b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.87", + "syn 2.0.89", ] [[package]] @@ -2428,15 +2518,16 @@ dependencies = [ [[package]] name = "tokio-util" -version = "0.7.12" +version = "0.7.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61e7c3654c13bcd040d4a03abee2c75b1d14a37b423cf5a813ceae1cc903ec6a" +checksum = "806fe8c2c87eccc8b3267cbae29ed3ab2d0bd37fca70ab622e46aaa9375ddb7d" dependencies = [ "bytes", "futures-core", "futures-sink", "pin-project-lite", "tokio", + "tracing", ] [[package]] @@ -2466,7 +2557,7 @@ version = "0.22.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ae48d6208a266e853d946088ed816055e556cc6028c5e8e2b84d9fa5dd7c7f5" dependencies = [ - "indexmap", + "indexmap 2.6.0", "serde", "serde_spanned", "toml_datetime", @@ -2491,9 +2582,9 @@ dependencies = [ [[package]] name = "tracing-core" -version = "0.1.32" +version = "0.1.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c06d3da6113f116aaee68e4d601191614c9053067f9ab7f6edbcb161237daa54" +checksum = "e672c95779cf947c5311f83787af4fa8fffd12fb27e4993211a84bdfd9610f9c" dependencies = [ "once_cell", ] @@ -2515,9 +2606,9 @@ dependencies = [ [[package]] name = "unicode-ident" -version = "1.0.13" +version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e91b56cd4cadaeb79bbf1a5645f6b4f8dc5bde8834ad5894a8db35fda9efa1fe" +checksum = "adb9e6ca4f869e1180728b7950e35922a7fc6397f7b641499e8f3ef06e50dc83" [[package]] name = "unicode-segmentation" @@ -2571,9 +2662,9 @@ checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" [[package]] name = "url" -version = "2.5.3" +version = "2.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8d157f1b96d14500ffdc1f10ba712e780825526c03d9a49b4d0324b0d9113ada" +checksum = "32f8b686cadd1473f4bd0117a5d28d36b1ade384ea9b5069a1c40aefed7fda60" dependencies = [ "form_urlencoded", "idna", @@ -2582,9 +2673,9 @@ dependencies = [ [[package]] name = "urlencoding" -version = "2.1.3" +version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" +checksum = "e8db7427f936968176eaa7cdf81b7f98b980b18495ec28f1b5791ac3bfe3eea9" [[package]] name = "utf16_iter" @@ -2665,7 +2756,7 @@ dependencies = [ "once_cell", "proc-macro2", "quote", - "syn 2.0.87", + "syn 2.0.89", "wasm-bindgen-shared", ] @@ -2699,7 +2790,7 @@ checksum = "26c6ab57572f7a24a4985830b120de1594465e5d500f24afe89e16b4e833ef68" dependencies = [ "proc-macro2", "quote", - "syn 2.0.87", + "syn 2.0.89", "wasm-bindgen-backend", "wasm-bindgen-shared", ] @@ -2961,16 +3052,25 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e9df38ee2d2c3c5948ea468a8406ff0db0b29ae1ffde1bcf20ef305bcc95c51" [[package]] -name = "yansi" -version = "1.0.1" +name = "yaml-rust" +version = "0.4.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" +checksum = "56c1936c4cc7a1c9ab21a1ebb602eb942ba868cbd44a99cb7cdc5892335e1c85" +dependencies = [ + "linked-hash-map", +] + +[[package]] +name = "yansi" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09041cd90cf85f7f8b2df60c646f853b7f535ce68f85244eb6731cf89fa498ec" [[package]] name = "yoke" -version = "0.7.4" +version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c5b1314b079b0930c31e3af543d8ee1757b1951ae1e1565ec704403a7240ca5" +checksum = "120e6aef9aa629e3d4f52dc8cc43a015c7724194c97dfaf45180d2daf2b77f40" dependencies = [ "serde", "stable_deref_trait", @@ -2980,13 +3080,13 @@ dependencies = [ [[package]] name = "yoke-derive" -version = "0.7.4" +version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28cc31741b18cb6f1d5ff12f5b7523e3d6eb0852bbbad19d73905511d9849b95" +checksum = "2380878cad4ac9aac1e2435f3eb4020e8374b5f13c296cb75b4620ff8e229154" dependencies = [ "proc-macro2", "quote", - "syn 2.0.87", + "syn 2.0.89", "synstructure", ] @@ -3008,27 +3108,27 @@ checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.87", + "syn 2.0.89", ] [[package]] name = "zerofrom" -version = "0.1.4" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91ec111ce797d0e0784a1116d0ddcdbea84322cd79e5d5ad173daeba4f93ab55" +checksum = "cff3ee08c995dee1859d998dea82f7374f2826091dd9cd47def953cae446cd2e" dependencies = [ "zerofrom-derive", ] [[package]] name = "zerofrom-derive" -version = "0.1.4" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ea7b4a3637ea8669cedf0f1fd5c286a17f3de97b8dd5a70a6c167a1730e63a5" +checksum = "595eed982f7d355beb85837f651fa22e90b3c044842dc7f2c2842c086f295808" dependencies = [ "proc-macro2", "quote", - "syn 2.0.87", + "syn 2.0.89", "synstructure", ] @@ -3057,5 +3157,5 @@ checksum = "6eafa6dfb17584ea3e2bd6e76e0cc15ad7af12b09abdd1ca55961bed9b1063c6" dependencies = [ "proc-macro2", "quote", - "syn 2.0.87", + "syn 2.0.89", ] diff --git a/Cargo.toml b/Cargo.toml index 7c48c4d..21e60bc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,7 +15,7 @@ exclude = [".github", "CONTRIBUTING.md", "*.log", "tags"] [dependencies] anyhow = "1.0.68" -backtrace = "0.3.67" +backtrace = "0.3.74" bimap = { version = "0.6.3", features = ["serde"] } chrono = { version = "0.4.38", features = ["serde"] } confy = { version = "0.6.0", default-features = false, features = [