diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 67831fb..924b8b3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,7 +1,7 @@ # Adapted from https://github.com/joshka/github-workflows/blob/main/.github/workflows/rust-release-plz.yml # Thanks to joshka for permission to use this template! -name: Create Release PR and publish to crates.io +name: Create Release PR and Publish Release permissions: pull-requests: write diff --git a/README.md b/README.md index a5a3b01..48a197d 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,7 @@ You can also clone this repo and run `make docker` to build a docker image local - [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 @@ -103,7 +104,8 @@ You can also clone this repo and run `make docker` to build a docker image local ### The Managarr CLI Managarr can be used in one of two ways: As a TUI, or as a CLI for managing your Servarrs. -All management features available in the TUI are also available in the CLI. +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. diff --git a/src/cli/radarr/get_command_handler.rs b/src/cli/radarr/get_command_handler.rs index 35004e4..1c398ab 100644 --- a/src/cli/radarr/get_command_handler.rs +++ b/src/cli/radarr/get_command_handler.rs @@ -21,6 +21,8 @@ mod get_command_handler_tests; pub enum RadarrGetCommand { #[command(about = "Get the shared settings for all indexers")] AllIndexerSettings, + #[command(about = "Fetch the host config for your Radarr instance")] + HostConfig, #[command(about = "Get detailed information for the movie with the given ID")] MovieDetails { #[arg( @@ -39,6 +41,8 @@ pub enum RadarrGetCommand { )] movie_id: i64, }, + #[command(about = "Fetch the security config for your Radarr instance")] + SecurityConfig, #[command(about = "Get the system status")] SystemStatus, } @@ -73,12 +77,18 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, RadarrGetCommand> for RadarrGetCommandHan RadarrGetCommand::AllIndexerSettings => { execute_network_event!(self, RadarrEvent::GetAllIndexerSettings); } + RadarrGetCommand::HostConfig => { + execute_network_event!(self, RadarrEvent::GetHostConfig); + } RadarrGetCommand::MovieDetails { movie_id } => { execute_network_event!(self, RadarrEvent::GetMovieDetails(Some(movie_id))); } RadarrGetCommand::MovieHistory { movie_id } => { execute_network_event!(self, RadarrEvent::GetMovieHistory(Some(movie_id))); } + RadarrGetCommand::SecurityConfig => { + execute_network_event!(self, RadarrEvent::GetSecurityConfig); + } RadarrGetCommand::SystemStatus => { execute_network_event!(self, RadarrEvent::GetStatus); } diff --git a/src/cli/radarr/get_command_handler_tests.rs b/src/cli/radarr/get_command_handler_tests.rs index eb9af0f..990e185 100644 --- a/src/cli/radarr/get_command_handler_tests.rs +++ b/src/cli/radarr/get_command_handler_tests.rs @@ -29,6 +29,14 @@ mod test { assert!(result.is_ok()); } + #[test] + fn test_get_host_config_has_no_arg_requirements() { + let result = + Cli::command().try_get_matches_from(["managarr", "radarr", "get", "host-config"]); + + assert!(result.is_ok()); + } + #[test] fn test_movie_details_requires_movie_id() { let result = @@ -81,6 +89,14 @@ mod test { assert!(result.is_ok()); } + #[test] + fn test_get_security_config_has_no_arg_requirements() { + let result = + Cli::command().try_get_matches_from(["managarr", "radarr", "get", "security-config"]); + + assert!(result.is_ok()); + } + #[test] fn test_system_status_has_no_arg_requirements() { let result = @@ -135,6 +151,29 @@ mod test { 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::(RadarrEvent::GetHostConfig.into())) + .times(1) + .returning(|_| { + Ok(Serdeable::Radarr(RadarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::default())); + let get_host_config_command = RadarrGetCommand::HostConfig; + + let result = + RadarrGetCommandHandler::with(&app_arc, get_host_config_command, &mut mock_network) + .handle() + .await; + + assert!(result.is_ok()); + } + #[tokio::test] async fn test_handle_get_movie_details_command() { let expected_movie_id = 1; @@ -187,6 +226,29 @@ mod test { 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::(RadarrEvent::GetSecurityConfig.into())) + .times(1) + .returning(|_| { + Ok(Serdeable::Radarr(RadarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::default())); + let get_security_config_command = RadarrGetCommand::SecurityConfig; + + let result = + RadarrGetCommandHandler::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/radarr_models.rs b/src/models/radarr_models.rs index 0f08ff8..5fa8ad5 100644 --- a/src/models/radarr_models.rs +++ b/src/models/radarr_models.rs @@ -57,6 +57,44 @@ 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, @@ -85,6 +123,26 @@ 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 { @@ -223,6 +281,18 @@ 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, +} + #[derive(Default, Deserialize, Serialize, Debug, Clone, Eq, PartialEq)] #[serde(rename_all = "camelCase")] pub struct Indexer { @@ -560,6 +630,17 @@ 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 { @@ -647,6 +728,7 @@ pub enum RadarrSerdeable { Credits(Vec), DiskSpaces(Vec), DownloadsResponse(DownloadsResponse), + HostConfig(HostConfig), Indexers(Vec), IndexerSettings(IndexerSettings), LogResponse(LogResponse), @@ -657,6 +739,7 @@ pub enum RadarrSerdeable { QueueEvents(Vec), Releases(Vec), RootFolders(Vec), + SecurityConfig(SecurityConfig), SystemStatus(SystemStatus), Tags(Vec), Tasks(Vec), @@ -686,6 +769,7 @@ serde_enum_from!( Credits(Vec), DiskSpaces(Vec), DownloadsResponse(DownloadsResponse), + HostConfig(HostConfig), Indexers(Vec), IndexerSettings(IndexerSettings), LogResponse(LogResponse), @@ -696,6 +780,7 @@ serde_enum_from!( QueueEvents(Vec), Releases(Vec), RootFolders(Vec), + SecurityConfig(SecurityConfig), SystemStatus(SystemStatus), Tags(Vec), Tasks(Vec), diff --git a/src/models/radarr_models_tests.rs b/src/models/radarr_models_tests.rs index e562eb1..4553f22 100644 --- a/src/models/radarr_models_tests.rs +++ b/src/models/radarr_models_tests.rs @@ -5,14 +5,41 @@ 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, - QueueEvent, RadarrSerdeable, Release, RootFolder, SystemStatus, Tag, Task, TaskName, Update, + 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, }, 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/network/radarr_network.rs b/src/network/radarr_network.rs index 648660a..e48d264 100644 --- a/src/network/radarr_network.rs +++ b/src/network/radarr_network.rs @@ -11,10 +11,10 @@ use crate::app::RadarrConfig; use crate::models::radarr_models::{ AddMovieBody, AddMovieSearchResult, AddOptions, AddRootFolderBody, BlocklistResponse, Collection, CollectionMovie, CommandBody, Credit, CreditType, DeleteMovieParams, DiskSpace, DownloadRecord, - DownloadsResponse, EditCollectionParams, EditIndexerParams, EditMovieParams, Indexer, + DownloadsResponse, EditCollectionParams, EditIndexerParams, EditMovieParams, HostConfig, Indexer, IndexerSettings, IndexerTestResult, LogResponse, Movie, MovieCommandBody, MovieHistoryItem, QualityProfile, QueueEvent, RadarrSerdeable, Release, ReleaseDownloadBody, RootFolder, - SystemStatus, Tag, Task, TaskName, Update, + SecurityConfig, SystemStatus, Tag, Task, TaskName, Update, }; use crate::models::servarr_data::radarr::modals::{ AddMovieModal, EditCollectionModal, EditIndexerModal, EditMovieModal, IndexerTestResultModalItem, @@ -50,6 +50,7 @@ pub enum RadarrEvent { GetBlocklist, GetCollections, GetDownloads, + GetHostConfig, GetIndexers, GetAllIndexerSettings, GetLogs(Option), @@ -62,6 +63,7 @@ pub enum RadarrEvent { GetQueuedEvents, GetReleases(Option), GetRootFolders, + GetSecurityConfig, GetStatus, GetTags, GetTasks, @@ -86,6 +88,7 @@ impl RadarrEvent { RadarrEvent::GetBlocklist => "/blocklist?page=1&pageSize=10000", RadarrEvent::GetCollections | RadarrEvent::EditCollection(_) => "/collection", RadarrEvent::GetDownloads | RadarrEvent::DeleteDownload(_) => "/queue", + RadarrEvent::GetHostConfig | RadarrEvent::GetSecurityConfig => "/config/host", RadarrEvent::GetIndexers | RadarrEvent::EditIndexer(_) | RadarrEvent::DeleteIndexer(_) => { "/indexer" } @@ -179,14 +182,15 @@ impl<'a, 'b> Network<'a, 'b> { self.edit_indexer(params).await.map(RadarrSerdeable::from) } RadarrEvent::EditMovie(params) => self.edit_movie(params).await.map(RadarrSerdeable::from), - RadarrEvent::GetBlocklist => self.get_blocklist().await.map(RadarrSerdeable::from), - RadarrEvent::GetCollections => self.get_collections().await.map(RadarrSerdeable::from), - RadarrEvent::GetDownloads => self.get_downloads().await.map(RadarrSerdeable::from), - RadarrEvent::GetIndexers => self.get_indexers().await.map(RadarrSerdeable::from), RadarrEvent::GetAllIndexerSettings => self .get_all_indexer_settings() .await .map(RadarrSerdeable::from), + RadarrEvent::GetBlocklist => self.get_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), + RadarrEvent::GetIndexers => self.get_indexers().await.map(RadarrSerdeable::from), RadarrEvent::GetLogs(events) => self.get_logs(events).await.map(RadarrSerdeable::from), RadarrEvent::GetMovieCredits(movie_id) => { self.get_credits(movie_id).await.map(RadarrSerdeable::from) @@ -209,6 +213,7 @@ 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::GetStatus => self.get_status().await.map(RadarrSerdeable::from), RadarrEvent::GetTags => self.get_tags().await.map(RadarrSerdeable::from), RadarrEvent::GetTasks => self.get_tasks().await.map(RadarrSerdeable::from), @@ -1354,6 +1359,22 @@ impl<'a, 'b> Network<'a, 'b> { .await } + async fn get_host_config(&mut self) -> Result { + info!("Fetching Radarr host config"); + + let request_props = self + .radarr_request_props_from( + RadarrEvent::GetHostConfig.resource(), + RequestMethod::Get, + None::<()>, + ) + .await; + + self + .handle_request::<(), HostConfig>(request_props, |_, _| ()) + .await + } + async fn get_indexers(&mut self) -> Result> { info!("Fetching Radarr indexers"); @@ -1765,6 +1786,22 @@ impl<'a, 'b> Network<'a, 'b> { .await } + async fn get_security_config(&mut self) -> Result { + info!("Fetching Radarr security config"); + + let request_props = self + .radarr_request_props_from( + RadarrEvent::GetSecurityConfig.resource(), + RequestMethod::Get, + None::<()>, + ) + .await; + + self + .handle_request::<(), SecurityConfig>(request_props, |_, _| ()) + .await + } + async fn get_status(&mut self) -> Result { info!("Fetching Radarr system status"); diff --git a/src/network/radarr_network_tests.rs b/src/network/radarr_network_tests.rs index c8e665f..f497290 100644 --- a/src/network/radarr_network_tests.rs +++ b/src/network/radarr_network_tests.rs @@ -174,6 +174,13 @@ mod test { assert_str_eq!(event.resource(), "/queue"); } + #[rstest] + fn test_resource_host_config( + #[values(RadarrEvent::GetHostConfig, RadarrEvent::GetSecurityConfig)] event: RadarrEvent, + ) { + assert_str_eq!(event.resource(), "/config/host"); + } + #[rstest] fn test_resource_command( #[values( @@ -2171,6 +2178,37 @@ mod test { } } + #[tokio::test] + async fn test_handle_get_host_config_event() { + let host_config_response = json!({ + "bindAddress": "*", + "port": 7878, + "urlBase": "some.test.site/radarr", + "instanceName": "Radarr", + "applicationUrl": "https://some.test.site:7878/radarr", + "enableSsl": true + }); + let response: HostConfig = serde_json::from_value(host_config_response.clone()).unwrap(); + let (async_server, app_arc, _server) = mock_radarr_api( + RequestMethod::Get, + None, + Some(host_config_response), + None, + RadarrEvent::GetHostConfig.resource(), + ) + .await; + let mut network = Network::new(&app_arc, CancellationToken::new()); + + if let RadarrSerdeable::HostConfig(host_config) = network + .handle_radarr_event(RadarrEvent::GetHostConfig) + .await + .unwrap() + { + async_server.assert_async().await; + assert_eq!(host_config, response); + } + } + #[tokio::test] async fn test_handle_get_indexers_event() { let indexers_response_json = json!([{ @@ -2711,7 +2749,7 @@ mod test { } #[tokio::test] - async fn test_add_tag() { + 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( @@ -2792,6 +2830,38 @@ mod test { } } + #[tokio::test] + async fn test_handle_get_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_radarr_api( + RequestMethod::Get, + None, + Some(security_config_response), + None, + RadarrEvent::GetSecurityConfig.resource(), + ) + .await; + let mut network = Network::new(&app_arc, CancellationToken::new()); + + if let RadarrSerdeable::SecurityConfig(security_config) = network + .handle_radarr_event(RadarrEvent::GetSecurityConfig) + .await + .unwrap() + { + async_server.assert_async().await; + assert_eq!(security_config, response); + } + } + #[tokio::test] async fn test_handle_get_movie_credits_event() { let credits_json = json!([