From 5d09b2402c88ef2933854ec029423552bd4b51bf Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Mon, 5 Jan 2026 10:58:48 -0700 Subject: [PATCH 01/61] feat: CLI support for listing artists --- src/app/mod.rs | 38 ++++++++- src/cli/lidarr/list_command_handler.rs | 59 +++++++++++++ src/cli/lidarr/mod.rs | 62 ++++++++++++++ src/cli/mod.rs | 10 +++ src/main.rs | 2 +- src/models/lidarr_models.rs | 84 +++++++++++++++++++ src/models/mod.rs | 13 +-- src/models/servarr_data/lidarr/lidarr_data.rs | 28 +++++++ .../servarr_data/lidarr/lidarr_data_tests.rs | 22 +++++ src/models/servarr_data/lidarr/mod.rs | 1 + src/models/servarr_data/mod.rs | 1 + src/network/lidarr_network/mod.rs | 70 ++++++++++++++++ src/network/mod.rs | 19 +++-- src/utils.rs | 11 ++- 14 files changed, 405 insertions(+), 15 deletions(-) create mode 100644 src/cli/lidarr/list_command_handler.rs create mode 100644 src/cli/lidarr/mod.rs create mode 100644 src/models/lidarr_models.rs create mode 100644 src/models/servarr_data/lidarr/lidarr_data.rs create mode 100644 src/models/servarr_data/lidarr/lidarr_data_tests.rs create mode 100644 src/models/servarr_data/lidarr/mod.rs create mode 100644 src/network/lidarr_network/mod.rs diff --git a/src/app/mod.rs b/src/app/mod.rs index a8de430..2b8acb6 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -13,6 +13,7 @@ use tokio_util::sync::CancellationToken; use veil::Redact; use crate::cli::Command; +use crate::models::servarr_data::lidarr::lidarr_data::ActiveLidarrBlock; use crate::models::servarr_data::radarr::radarr_data::{ActiveRadarrBlock, RadarrData}; use crate::models::servarr_data::sonarr::sonarr_data::{ActiveSonarrBlock, SonarrData}; use crate::models::servarr_models::KeybindingItem; @@ -96,6 +97,26 @@ impl App<'_> { server_tabs.extend(sonarr_tabs); } + if let Some(lidarr_configs) = config.lidarr { + let mut unnamed_idx = 0; + let lidarr_tabs = lidarr_configs.into_iter().map(|lidarr_config| { + let name = if let Some(name) = lidarr_config.name.clone() { + name + } else { + unnamed_idx += 1; + format!("Lidarr {unnamed_idx}") + }; + + TabRoute { + title: name, + route: ActiveLidarrBlock::Artists.into(), + contextual_help: None, + config: Some(lidarr_config), + } + }); + server_tabs.extend(lidarr_tabs); + } + let weight_sorted_tabs = server_tabs .into_iter() .sorted_by(|tab1, tab2| { @@ -303,13 +324,14 @@ pub struct Data<'a> { #[derive(Debug, Deserialize, Serialize, Default, Clone)] pub struct AppConfig { pub theme: Option, + pub lidarr: Option>, pub radarr: Option>, pub sonarr: Option>, } impl AppConfig { pub fn validate(&self) { - if self.radarr.is_none() && self.sonarr.is_none() { + if self.lidarr.is_none() && self.radarr.is_none() && self.sonarr.is_none() { log_and_print_error( "No Servarr configuration provided in the specified configuration file".to_owned(), ); @@ -323,6 +345,10 @@ impl AppConfig { if let Some(sonarr_configs) = &self.sonarr { sonarr_configs.iter().for_each(|config| config.validate()); } + + if let Some(lidarr_configs) = &self.lidarr { + lidarr_configs.iter().for_each(|config| config.validate()); + } } pub fn verify_config_present_for_cli(&self, command: &Command) { @@ -340,6 +366,10 @@ impl AppConfig { msg("Sonarr"); process::exit(1); } + Command::Lidarr(_) if self.lidarr.is_none() => { + msg("Lidarr"); + process::exit(1); + } _ => (), } } @@ -356,6 +386,12 @@ impl AppConfig { sonarr_config.post_process_initialization(); } } + + if let Some(lidarr_configs) = self.lidarr.as_mut() { + for lidarr_config in lidarr_configs { + lidarr_config.post_process_initialization(); + } + } } } diff --git a/src/cli/lidarr/list_command_handler.rs b/src/cli/lidarr/list_command_handler.rs new file mode 100644 index 0000000..445e153 --- /dev/null +++ b/src/cli/lidarr/list_command_handler.rs @@ -0,0 +1,59 @@ +use std::sync::Arc; + +use anyhow::Result; +use clap::Subcommand; +use tokio::sync::Mutex; + +use crate::{ + app::App, + cli::{CliCommandHandler, Command}, + network::{NetworkTrait, lidarr_network::LidarrEvent}, +}; + +use super::LidarrCommand; + +#[derive(Debug, Clone, PartialEq, Eq, Subcommand)] +pub enum LidarrListCommand { + #[command(about = "List all artists in your Lidarr library")] + Artists, +} + +impl From for Command { + fn from(value: LidarrListCommand) -> Self { + Command::Lidarr(LidarrCommand::List(value)) + } +} + +pub(super) struct LidarrListCommandHandler<'a, 'b> { + _app: &'a Arc>>, + command: LidarrListCommand, + network: &'a mut dyn NetworkTrait, +} + +impl<'a, 'b> CliCommandHandler<'a, 'b, LidarrListCommand> for LidarrListCommandHandler<'a, 'b> { + fn with( + app: &'a Arc>>, + command: LidarrListCommand, + network: &'a mut dyn NetworkTrait, + ) -> Self { + LidarrListCommandHandler { + _app: app, + command, + network, + } + } + + async fn handle(self) -> Result { + let result = match self.command { + LidarrListCommand::Artists => { + let resp = self + .network + .handle_network_event(LidarrEvent::ListArtists.into()) + .await?; + serde_json::to_string_pretty(&resp)? + } + }; + + Ok(result) + } +} diff --git a/src/cli/lidarr/mod.rs b/src/cli/lidarr/mod.rs new file mode 100644 index 0000000..f1913f4 --- /dev/null +++ b/src/cli/lidarr/mod.rs @@ -0,0 +1,62 @@ +use std::sync::Arc; + +use anyhow::Result; +use clap::Subcommand; +use list_command_handler::{LidarrListCommand, LidarrListCommandHandler}; +use tokio::sync::Mutex; + +use crate::{ + app::App, + network::NetworkTrait, +}; + +use super::{CliCommandHandler, Command}; + +mod list_command_handler; + +#[derive(Debug, Clone, PartialEq, Eq, Subcommand)] +pub enum LidarrCommand { + #[command( + subcommand, + about = "Commands to list attributes from your Lidarr instance" + )] + List(LidarrListCommand), +} + +impl From for Command { + fn from(lidarr_command: LidarrCommand) -> Command { + Command::Lidarr(lidarr_command) + } +} + +pub(super) struct LidarrCliHandler<'a, 'b> { + app: &'a Arc>>, + command: LidarrCommand, + network: &'a mut dyn NetworkTrait, +} + +impl<'a, 'b> CliCommandHandler<'a, 'b, LidarrCommand> for LidarrCliHandler<'a, 'b> { + fn with( + app: &'a Arc>>, + command: LidarrCommand, + network: &'a mut dyn NetworkTrait, + ) -> Self { + LidarrCliHandler { + app, + command, + network, + } + } + + async fn handle(self) -> Result { + let result = match self.command { + LidarrCommand::List(list_command) => { + LidarrListCommandHandler::with(self.app, list_command, self.network) + .handle() + .await? + } + }; + + Ok(result) + } +} diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 8d0c6d2..872a18e 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -3,12 +3,14 @@ use std::sync::Arc; use anyhow::Result; use clap::{Subcommand, command}; use clap_complete::Shell; +use lidarr::{LidarrCliHandler, LidarrCommand}; use radarr::{RadarrCliHandler, RadarrCommand}; use sonarr::{SonarrCliHandler, SonarrCommand}; use tokio::sync::Mutex; use crate::{app::App, network::NetworkTrait}; +pub mod lidarr; pub mod radarr; pub mod sonarr; @@ -24,6 +26,9 @@ pub enum Command { #[command(subcommand, about = "Commands for manging your Sonarr instance")] Sonarr(SonarrCommand), + #[command(subcommand, about = "Commands for manging your Lidarr instance")] + Lidarr(LidarrCommand), + #[command( arg_required_else_help = true, about = "Generate shell completions for the Managarr CLI" @@ -61,6 +66,11 @@ pub(crate) async fn handle_command( .handle() .await? } + Command::Lidarr(lidarr_command) => { + LidarrCliHandler::with(app, lidarr_command, network) + .handle() + .await? + } _ => String::new(), }; diff --git a/src/main.rs b/src/main.rs index b611d22..9324092 100644 --- a/src/main.rs +++ b/src/main.rs @@ -145,7 +145,7 @@ async fn main() -> Result<()> { match args.command { Some(command) => match command { - Command::Radarr(_) | Command::Sonarr(_) => { + Command::Radarr(_) | Command::Sonarr(_) | Command::Lidarr(_) => { if spinner_disabled { start_cli_no_spinner(config, reqwest_client, cancellation_token, app, command).await; } else { diff --git a/src/models/lidarr_models.rs b/src/models/lidarr_models.rs new file mode 100644 index 0000000..dcc19df --- /dev/null +++ b/src/models/lidarr_models.rs @@ -0,0 +1,84 @@ +use chrono::{DateTime, Utc}; +use derivative::Derivative; +use serde::{Deserialize, Serialize}; +use serde_json::{Number, Value}; + +use super::{HorizontallyScrollableText, Serdeable}; +use crate::serde_enum_from; + +#[derive(Derivative, Serialize, Deserialize, Debug, Default, Clone, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct Artist { + #[serde(deserialize_with = "super::from_i64")] + pub id: i64, + pub mb_id: String, + pub artist_name: HorizontallyScrollableText, + pub foreign_artist_id: String, + pub status: ArtistStatus, + pub overview: Option, + pub artist_type: Option, + pub disambiguation: Option, + pub path: String, + #[serde(deserialize_with = "super::from_i64")] + pub quality_profile_id: i64, + #[serde(deserialize_with = "super::from_i64")] + pub metadata_profile_id: i64, + pub monitored: bool, + pub genres: Vec, + pub tags: Vec, + pub added: DateTime, + pub ratings: Option, + pub statistics: Option, +} + +#[derive(Serialize, Deserialize, Default, PartialEq, Eq, Clone, Copy, Debug)] +#[serde(rename_all = "camelCase")] +pub enum ArtistStatus { + #[default] + Continuing, + Ended, + Deleted, +} + +#[derive(Derivative, Serialize, Deserialize, Debug, Default, Clone, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct Ratings { + #[serde(deserialize_with = "super::from_i64")] + pub votes: i64, + #[serde(deserialize_with = "super::from_f64")] + pub value: f64, +} + +impl Eq for Ratings {} + +#[derive(Derivative, Serialize, Deserialize, Debug, Default, Clone, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct ArtistStatistics { + #[serde(deserialize_with = "super::from_i64")] + pub album_count: i64, + #[serde(deserialize_with = "super::from_i64")] + pub track_file_count: i64, + #[serde(deserialize_with = "super::from_i64")] + pub track_count: i64, + #[serde(deserialize_with = "super::from_i64")] + pub total_track_count: i64, + #[serde(deserialize_with = "super::from_i64")] + pub size_on_disk: i64, + #[serde(deserialize_with = "super::from_f64")] + pub percent_of_tracks: f64, +} + +impl Eq for ArtistStatistics {} + +impl From for Serdeable { + fn from(value: LidarrSerdeable) -> Serdeable { + Serdeable::Lidarr(value) + } +} + +serde_enum_from!( + LidarrSerdeable { + Artists(Vec), + Value(Value), + } +); diff --git a/src/models/mod.rs b/src/models/mod.rs index fc8d5b6..363ffb8 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -1,16 +1,19 @@ use std::fmt::{Debug, Display, Formatter}; use std::sync::atomic::{AtomicUsize, Ordering}; -use crate::app::ServarrConfig; use crate::app::context_clues::ContextClue; +use crate::app::ServarrConfig; +use crate::models::servarr_data::lidarr::lidarr_data::ActiveLidarrBlock; use crate::models::servarr_data::radarr::radarr_data::ActiveRadarrBlock; +use lidarr_models::LidarrSerdeable; use radarr_models::RadarrSerdeable; use regex::Regex; -use serde::{Deserialize, Deserializer, Serialize, Serializer, de}; +use serde::{de, Deserialize, Deserializer, Serialize, Serializer}; use serde_json::Number; use servarr_data::sonarr::sonarr_data::ActiveSonarrBlock; use sonarr_models::SonarrSerdeable; +pub mod lidarr_models; pub mod radarr_models; pub mod servarr_data; pub mod servarr_models; @@ -30,7 +33,7 @@ pub enum Route { Radarr(ActiveRadarrBlock, Option), Sonarr(ActiveSonarrBlock, Option), Readarr, - Lidarr, + Lidarr(ActiveLidarrBlock, Option), Whisparr, Bazarr, Prowlarr, @@ -43,6 +46,7 @@ pub enum Route { pub enum Serdeable { Radarr(RadarrSerdeable), Sonarr(SonarrSerdeable), + Lidarr(LidarrSerdeable), } pub trait Scrollable { @@ -289,8 +293,7 @@ impl TabState { TabState { tabs, index: 0 } } - // Allowing this code for now since we'll eventually be implementing additional Servarr support, and we'll need it then - #[allow(dead_code)] + #[cfg(test)] pub fn set_index(&mut self, index: usize) -> &TabRoute { self.index = index; &self.tabs[self.index] diff --git a/src/models/servarr_data/lidarr/lidarr_data.rs b/src/models/servarr_data/lidarr/lidarr_data.rs new file mode 100644 index 0000000..185e9f1 --- /dev/null +++ b/src/models/servarr_data/lidarr/lidarr_data.rs @@ -0,0 +1,28 @@ +use strum::EnumIter; +#[cfg(test)] +use strum::{Display, EnumString}; + +use crate::models::Route; + +#[cfg(test)] +#[path = "lidarr_data_tests.rs"] +mod lidarr_data_tests; + +#[derive(Clone, Copy, PartialEq, Eq, Debug, Default, EnumIter)] +#[cfg_attr(test, derive(Display, EnumString))] +pub enum ActiveLidarrBlock { + #[default] + Artists, +} + +impl From for Route { + fn from(active_lidarr_block: ActiveLidarrBlock) -> Route { + Route::Lidarr(active_lidarr_block, None) + } +} + +impl From<(ActiveLidarrBlock, Option)> for Route { + fn from(value: (ActiveLidarrBlock, Option)) -> Route { + Route::Lidarr(value.0, value.1) + } +} diff --git a/src/models/servarr_data/lidarr/lidarr_data_tests.rs b/src/models/servarr_data/lidarr/lidarr_data_tests.rs new file mode 100644 index 0000000..256b158 --- /dev/null +++ b/src/models/servarr_data/lidarr/lidarr_data_tests.rs @@ -0,0 +1,22 @@ +#[cfg(test)] +mod tests { + use pretty_assertions::assert_eq; + + use crate::models::{servarr_data::lidarr::lidarr_data::ActiveLidarrBlock, Route}; + + #[test] + fn test_from_active_lidarr_block_to_route() { + assert_eq!( + Route::from(ActiveLidarrBlock::Artists), + Route::Lidarr(ActiveLidarrBlock::Artists, None) + ); + } + + #[test] + fn test_from_tuple_to_route_with_context() { + assert_eq!( + Route::from((ActiveLidarrBlock::Artists, Some(ActiveLidarrBlock::Artists))), + Route::Lidarr(ActiveLidarrBlock::Artists, Some(ActiveLidarrBlock::Artists),) + ); + } +} diff --git a/src/models/servarr_data/lidarr/mod.rs b/src/models/servarr_data/lidarr/mod.rs new file mode 100644 index 0000000..81f6a27 --- /dev/null +++ b/src/models/servarr_data/lidarr/mod.rs @@ -0,0 +1 @@ +pub mod lidarr_data; diff --git a/src/models/servarr_data/mod.rs b/src/models/servarr_data/mod.rs index 1545315..256f0bc 100644 --- a/src/models/servarr_data/mod.rs +++ b/src/models/servarr_data/mod.rs @@ -1,5 +1,6 @@ use crate::models::Route; +pub mod lidarr; pub mod modals; pub mod radarr; pub mod sonarr; diff --git a/src/network/lidarr_network/mod.rs b/src/network/lidarr_network/mod.rs new file mode 100644 index 0000000..36119b0 --- /dev/null +++ b/src/network/lidarr_network/mod.rs @@ -0,0 +1,70 @@ +use anyhow::Result; +use log::info; + +use super::{Network, NetworkEvent, NetworkResource}; +use crate::{ + models::lidarr_models::{Artist, LidarrSerdeable}, + network::RequestMethod, +}; + +#[derive(Debug, Eq, PartialEq, Clone)] +pub enum LidarrEvent { + HealthCheck, + ListArtists, +} + +impl NetworkResource for LidarrEvent { + fn resource(&self) -> &'static str { + match &self { + LidarrEvent::HealthCheck => "/health", + LidarrEvent::ListArtists => "/artist", + } + } +} + +impl From for NetworkEvent { + fn from(lidarr_event: LidarrEvent) -> Self { + NetworkEvent::Lidarr(lidarr_event) + } +} + +impl Network<'_, '_> { + pub async fn handle_lidarr_event( + &mut self, + lidarr_event: LidarrEvent, + ) -> Result { + match lidarr_event { + LidarrEvent::HealthCheck => self + .get_lidarr_healthcheck() + .await + .map(LidarrSerdeable::from), + LidarrEvent::ListArtists => self.list_artists().await.map(LidarrSerdeable::from), + } + } + + async fn get_lidarr_healthcheck(&mut self) -> Result<()> { + info!("Performing Lidarr health check"); + let event = LidarrEvent::HealthCheck; + + let request_props = self + .request_props_from(event, RequestMethod::Get, None::<()>, None, None) + .await; + + self + .handle_request::<(), ()>(request_props, |_, _| ()) + .await + } + + async fn list_artists(&mut self) -> Result> { + info!("Fetching Lidarr artists"); + let event = LidarrEvent::ListArtists; + + let request_props = self + .request_props_from(event, RequestMethod::Get, None::<()>, None, None) + .await; + + self + .handle_request::<(), Vec>(request_props, |_, _| ()) + .await + } +} diff --git a/src/network/mod.rs b/src/network/mod.rs index b3bf0f4..3a73a5a 100644 --- a/src/network/mod.rs +++ b/src/network/mod.rs @@ -8,6 +8,7 @@ use regex::Regex; use reqwest::{Client, RequestBuilder}; use serde::Serialize; use serde::de::DeserializeOwned; +use lidarr_network::LidarrEvent; use sonarr_network::SonarrEvent; use strum_macros::Display; use tokio::select; @@ -21,6 +22,7 @@ use crate::network::radarr_network::RadarrEvent; use mockall::automock; use reqwest::header::HeaderMap; +pub mod lidarr_network; pub mod radarr_network; pub mod sonarr_network; mod utils; @@ -44,6 +46,7 @@ pub trait NetworkResource { pub enum NetworkEvent { Radarr(RadarrEvent), Sonarr(SonarrEvent), + Lidarr(LidarrEvent), } #[derive(Clone)] @@ -65,6 +68,10 @@ impl NetworkTrait for Network<'_, '_> { .handle_sonarr_event(sonarr_event) .await .map(Serdeable::from), + NetworkEvent::Lidarr(lidarr_event) => self + .handle_lidarr_event(lidarr_event) + .await + .map(Serdeable::from), }; let mut app = self.app.lock().await; @@ -229,12 +236,14 @@ impl<'a, 'b> Network<'a, 'b> { .get_active_config() .as_ref() .expect("Servarr config is undefined"); - let default_port = match network_event.into() { - NetworkEvent::Radarr(_) => 7878, - NetworkEvent::Sonarr(_) => 8989, + let network_event_type = network_event.into(); + let (default_port, api_version) = match &network_event_type { + NetworkEvent::Radarr(_) => (7878, "v3"), + NetworkEvent::Sonarr(_) => (8989, "v3"), + NetworkEvent::Lidarr(_) => (8686, "v1"), }; let mut uri = if let Some(servarr_uri) = uri { - format!("{servarr_uri}/api/v3{resource}") + format!("{servarr_uri}/api/{api_version}{resource}") } else { let protocol = if ssl_cert_path.is_some() { "https" @@ -243,7 +252,7 @@ impl<'a, 'b> Network<'a, 'b> { }; let host = host.as_ref().unwrap(); format!( - "{protocol}://{host}:{}/api/v3{resource}", + "{protocol}://{host}:{}/api/{api_version}{resource}", port.unwrap_or(default_port) ) }; diff --git a/src/utils.rs b/src/utils.rs index c157755..b43e9b8 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -6,10 +6,10 @@ use std::sync::Arc; use std::time::Duration; use anyhow::Result; -use anyhow::{Context, anyhow}; +use anyhow::{anyhow, Context}; use colored::Colorize; use indicatif::{ProgressBar, ProgressStyle}; -use log::{LevelFilter, error}; +use log::{error, LevelFilter}; use log4rs::append::file::FileAppender; use log4rs::config::{Appender, Root}; use log4rs::encode::pattern::PatternEncoder; @@ -18,7 +18,7 @@ use reqwest::{Certificate, Client}; use tokio::sync::Mutex; use tokio_util::sync::CancellationToken; -use crate::app::{App, AppConfig, log_and_print_error}; +use crate::app::{log_and_print_error, App, AppConfig}; use crate::cli::{self, Command}; use crate::network::Network; use crate::ui::theme::ThemeDefinitionsWrapper; @@ -318,6 +318,11 @@ pub fn select_cli_configuration( config.sonarr.as_ref().expect("Sonarr config must exist")[0].clone(); app.server_tabs.select_tab_by_config(&default_sonarr_config); } + Command::Lidarr(_) => { + let default_lidarr_config = + config.lidarr.as_ref().expect("Lidarr config must exist")[0].clone(); + app.server_tabs.select_tab_by_config(&default_lidarr_config); + } _ => (), } } -- 2.52.0 From e61537942b91510d98beec3489ac1e276bfe03e4 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Mon, 5 Jan 2026 11:28:35 -0700 Subject: [PATCH 02/61] test: Implemented tests for the Lidarr list artists command --- src/app/app_tests.rs | 1 + src/app/mod.rs | 12 ++ src/cli/lidarr/lidarr_command_tests.rs | 37 ++++ src/cli/lidarr/list_command_handler.rs | 4 + src/cli/lidarr/list_command_handler_tests.rs | 70 ++++++ src/cli/lidarr/mod.rs | 4 + src/handlers/handler_proptest.rs | 33 +-- src/handlers/handlers_tests.rs | 32 +-- src/models/lidarr_models.rs | 4 + src/models/lidarr_models_tests.rs | 201 ++++++++++++++++++ .../lidarr_network/lidarr_network_tests.rs | 72 +++++++ src/network/lidarr_network/mod.rs | 4 + src/network/network_tests.rs | 12 +- ..._tests__radarr_ui_renders_library_tab.snap | 2 +- ...rr_ui_renders_library_tab_error_popup.snap | 2 +- ...arr_ui_renders_library_tab_with_error.snap | 2 +- 16 files changed, 442 insertions(+), 50 deletions(-) create mode 100644 src/cli/lidarr/lidarr_command_tests.rs create mode 100644 src/cli/lidarr/list_command_handler_tests.rs create mode 100644 src/models/lidarr_models_tests.rs create mode 100644 src/network/lidarr_network/lidarr_network_tests.rs diff --git a/src/app/app_tests.rs b/src/app/app_tests.rs index 5b85e37..6d9b1f8 100644 --- a/src/app/app_tests.rs +++ b/src/app/app_tests.rs @@ -35,6 +35,7 @@ mod tests { theme: None, radarr: Some(vec![radarr_config_1.clone(), radarr_config_2.clone()]), sonarr: Some(vec![sonarr_config_1.clone(), sonarr_config_2.clone()]), + lidarr: None, }; let expected_tab_routes = vec![ TabRoute { diff --git a/src/app/mod.rs b/src/app/mod.rs index 2b8acb6..68edf1f 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -285,6 +285,12 @@ impl App<'_> { contextual_help: None, config: Some(ServarrConfig::default()), }, + TabRoute { + title: "Lidarr".to_owned(), + route: ActiveLidarrBlock::Artists.into(), + contextual_help: None, + config: Some(ServarrConfig::default()), + }, ]), ..App::default() } @@ -309,6 +315,12 @@ impl App<'_> { contextual_help: None, config: Some(ServarrConfig::default()), }, + TabRoute { + title: "Lidarr".to_owned(), + route: ActiveLidarrBlock::Artists.into(), + contextual_help: None, + config: Some(ServarrConfig::default()), + }, ]), ..App::default() } diff --git a/src/cli/lidarr/lidarr_command_tests.rs b/src/cli/lidarr/lidarr_command_tests.rs new file mode 100644 index 0000000..bf0ffaa --- /dev/null +++ b/src/cli/lidarr/lidarr_command_tests.rs @@ -0,0 +1,37 @@ +#[cfg(test)] +mod tests { + use crate::cli::{ + lidarr::{list_command_handler::LidarrListCommand, LidarrCommand}, + Command, + }; + use crate::Cli; + use clap::CommandFactory; + use pretty_assertions::assert_eq; + + #[test] + fn test_lidarr_command_from() { + let command = LidarrCommand::List(LidarrListCommand::Artists); + + let result = Command::from(command.clone()); + + assert_eq!(result, Command::Lidarr(command)); + } + + mod cli { + use super::*; + + #[test] + fn test_list_artists_has_no_arg_requirements() { + let result = Cli::command().try_get_matches_from(["managarr", "lidarr", "list", "artists"]); + + assert_ok!(&result); + } + + #[test] + fn test_lidarr_list_subcommand_requires_subcommand() { + let result = Cli::command().try_get_matches_from(["managarr", "lidarr", "list"]); + + assert_err!(&result); + } + } +} diff --git a/src/cli/lidarr/list_command_handler.rs b/src/cli/lidarr/list_command_handler.rs index 445e153..cf56ccb 100644 --- a/src/cli/lidarr/list_command_handler.rs +++ b/src/cli/lidarr/list_command_handler.rs @@ -12,6 +12,10 @@ use crate::{ use super::LidarrCommand; +#[cfg(test)] +#[path = "list_command_handler_tests.rs"] +mod list_command_handler_tests; + #[derive(Debug, Clone, PartialEq, Eq, Subcommand)] pub enum LidarrListCommand { #[command(about = "List all artists in your Lidarr library")] diff --git a/src/cli/lidarr/list_command_handler_tests.rs b/src/cli/lidarr/list_command_handler_tests.rs new file mode 100644 index 0000000..6dfbae9 --- /dev/null +++ b/src/cli/lidarr/list_command_handler_tests.rs @@ -0,0 +1,70 @@ +#[cfg(test)] +mod tests { + use crate::Cli; + use crate::cli::{ + Command, + lidarr::{LidarrCommand, list_command_handler::LidarrListCommand}, + }; + use clap::CommandFactory; + use pretty_assertions::assert_eq; + + #[test] + fn test_lidarr_list_command_from() { + let command = LidarrListCommand::Artists; + + let result = Command::from(command.clone()); + + assert_eq!(result, Command::Lidarr(LidarrCommand::List(command))); + } + + mod cli { + use super::*; + + #[test] + fn test_list_artists_has_no_arg_requirements() { + let result = Cli::command().try_get_matches_from(["managarr", "lidarr", "list", "artists"]); + + assert_ok!(&result); + } + } + + mod handler { + use std::sync::Arc; + + use mockall::predicate::eq; + use serde_json::json; + use tokio::sync::Mutex; + + use crate::cli::CliCommandHandler; + use crate::cli::lidarr::list_command_handler::{LidarrListCommand, LidarrListCommandHandler}; + use crate::models::Serdeable; + use crate::models::lidarr_models::LidarrSerdeable; + use crate::network::lidarr_network::LidarrEvent; + use crate::{ + app::App, + network::{MockNetworkTrait, NetworkEvent}, + }; + + #[tokio::test] + async fn test_handle_list_artists_command() { + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::(LidarrEvent::ListArtists.into())) + .times(1) + .returning(|_| { + Ok(Serdeable::Lidarr(LidarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::test_default())); + + let result = + LidarrListCommandHandler::with(&app_arc, LidarrListCommand::Artists, &mut mock_network) + .handle() + .await; + + assert_ok!(&result); + } + } +} diff --git a/src/cli/lidarr/mod.rs b/src/cli/lidarr/mod.rs index f1913f4..99dab8b 100644 --- a/src/cli/lidarr/mod.rs +++ b/src/cli/lidarr/mod.rs @@ -14,6 +14,10 @@ use super::{CliCommandHandler, Command}; mod list_command_handler; +#[cfg(test)] +#[path = "lidarr_command_tests.rs"] +mod lidarr_command_tests; + #[derive(Debug, Clone, PartialEq, Eq, Subcommand)] pub enum LidarrCommand { #[command( diff --git a/src/handlers/handler_proptest.rs b/src/handlers/handler_proptest.rs index 83fe8d6..1e3df75 100644 --- a/src/handlers/handler_proptest.rs +++ b/src/handlers/handler_proptest.rs @@ -4,13 +4,12 @@ mod property_tests { use crate::app::App; use crate::handlers::handler_test_utils::test_utils::proptest_helpers::*; + use crate::models::radarr_models::Movie; use crate::models::servarr_data::radarr::radarr_data::ActiveRadarrBlock; use crate::models::stateful_table::StatefulTable; - use crate::models::radarr_models::Movie; - use crate::models::{Scrollable, Paginated}; + use crate::models::{Paginated, Scrollable}; proptest! { - /// Property test: Table never panics on index selection #[test] fn test_table_index_selection_safety( list_size in list_size(), @@ -25,19 +24,15 @@ mod property_tests { table.set_items(movies); - // Try to select an arbitrary index if index < list_size { table.select_index(Some(index)); let selected = table.current_selection(); prop_assert_eq!(selected.id, index as i64); } else { - // Out of bounds selection should be safe table.select_index(Some(index)); - // Should not panic, selection stays valid } } - /// Property test: Table state remains consistent after scroll operations #[test] fn test_table_scroll_consistency( list_size in list_size(), @@ -53,42 +48,34 @@ mod property_tests { table.set_items(movies); let initial_id = table.current_selection().id; - // Scroll down multiple times for _ in 0..scroll_amount { table.scroll_down(); } let after_down_id = table.current_selection().id; - // Position should increase (up to max) prop_assert!(after_down_id >= initial_id); prop_assert!(after_down_id < list_size as i64); - // Scroll back up for _ in 0..scroll_amount { table.scroll_up(); } - // Should return to initial position (or 0 if we hit the top) prop_assert!(table.current_selection().id <= initial_id); } - /// Property test: Empty tables handle operations gracefully #[test] fn test_empty_table_safety(_scroll_ops in 0usize..50) { let table = StatefulTable::::default(); - // Empty table operations should be safe prop_assert!(table.is_empty()); prop_assert!(table.items.is_empty()); } - /// Property test: Navigation operations maintain consistency #[test] fn test_navigation_consistency(pushes in 1usize..20) { let mut app = App::test_default(); let initial_route = app.get_current_route(); - // Push multiple routes let routes = vec![ ActiveRadarrBlock::Movies, ActiveRadarrBlock::Collections, @@ -101,34 +88,27 @@ mod property_tests { app.push_navigation_stack(route.into()); } - // Current route should be the last pushed let last_pushed = routes[(pushes - 1) % routes.len()]; prop_assert_eq!(app.get_current_route(), last_pushed.into()); - // Pop all routes for _ in 0..pushes { app.pop_navigation_stack(); } - // Should return to initial route prop_assert_eq!(app.get_current_route(), initial_route); } - /// Property test: String input handling is safe #[test] fn test_string_input_safety(input in text_input_string()) { - // String operations should never panic let _lowercase = input.to_lowercase(); let _uppercase = input.to_uppercase(); let _trimmed = input.trim(); let _len = input.len(); let _chars: Vec = input.chars().collect(); - // All operations completed without panic prop_assert!(true); } - /// Property test: Table maintains data integrity after operations #[test] fn test_table_data_integrity( list_size in 1usize..100 @@ -144,16 +124,13 @@ mod property_tests { table.set_items(movies.clone()); let original_count = table.items.len(); - // Count should remain the same after various operations prop_assert_eq!(table.items.len(), original_count); - // All original items should still be present for movie in &movies { prop_assert!(table.items.iter().any(|m| m.id == movie.id)); } } - /// Property test: Page up/down maintains bounds #[test] fn test_page_navigation_bounds( list_size in list_size(), @@ -168,7 +145,6 @@ mod property_tests { table.set_items(movies); - // Perform page operations for i in 0..page_ops { if i % 2 == 0 { table.page_down(); @@ -176,14 +152,12 @@ mod property_tests { table.page_up(); } - // Should never exceed bounds let current = table.current_selection(); prop_assert!(current.id >= 0); prop_assert!(current.id < list_size as i64); } } - /// Property test: Table filtering reduces or maintains size #[test] fn test_table_filter_size_invariant( list_size in list_size(), @@ -200,7 +174,6 @@ mod property_tests { table.set_items(movies.clone()); let original_size = table.items.len(); - // Apply filter if !filter_term.is_empty() { let filtered: Vec = movies.into_iter() .filter(|m| m.title.text.to_lowercase().contains(&filter_term.to_lowercase())) @@ -208,10 +181,8 @@ mod property_tests { table.set_items(filtered); } - // Filtered size should be <= original prop_assert!(table.items.len() <= original_size); - // Selection should still be valid if table not empty if !table.items.is_empty() { let current = table.current_selection(); prop_assert!(current.id >= 0); diff --git a/src/handlers/handlers_tests.rs b/src/handlers/handlers_tests.rs index 650511c..48cc18d 100644 --- a/src/handlers/handlers_tests.rs +++ b/src/handlers/handlers_tests.rs @@ -9,22 +9,23 @@ mod tests { use rstest::rstest; use tokio_util::sync::CancellationToken; - use crate::app::App; use crate::app::context_clues::SERVARR_CONTEXT_CLUES; - use crate::app::key_binding::{DEFAULT_KEYBINDINGS, KeyBinding}; + use crate::app::key_binding::{KeyBinding, DEFAULT_KEYBINDINGS}; use crate::app::radarr::radarr_context_clues::{ LIBRARY_CONTEXT_CLUES, MOVIE_DETAILS_CONTEXT_CLUES, }; + use crate::app::App; use crate::event::Key; use crate::handlers::{handle_clear_errors, handle_prompt_toggle}; use crate::handlers::{handle_events, populate_keymapping_table}; - use crate::models::HorizontallyScrollableText; - use crate::models::Route; - use crate::models::servarr_data::ActiveKeybindingBlock; + use crate::models::servarr_data::lidarr::lidarr_data::ActiveLidarrBlock; use crate::models::servarr_data::radarr::radarr_data::{ActiveRadarrBlock, RadarrData}; use crate::models::servarr_data::sonarr::sonarr_data::ActiveSonarrBlock; + use crate::models::servarr_data::ActiveKeybindingBlock; use crate::models::servarr_models::KeybindingItem; use crate::models::stateful_table::StatefulTable; + use crate::models::HorizontallyScrollableText; + use crate::models::Route; #[test] fn test_handle_clear_errors() { @@ -60,11 +61,16 @@ mod tests { } #[rstest] - #[case(0, ActiveSonarrBlock::Series, ActiveSonarrBlock::Series)] - #[case(1, ActiveRadarrBlock::Movies, ActiveRadarrBlock::Movies)] - fn test_handle_change_tabs(#[case] index: usize, #[case] left_block: T, #[case] right_block: T) - where + #[case(0, ActiveLidarrBlock::Artists, ActiveSonarrBlock::Series)] + #[case(1, ActiveRadarrBlock::Movies, ActiveLidarrBlock::Artists)] + #[case(2, ActiveSonarrBlock::Series, ActiveRadarrBlock::Movies)] + fn test_handle_change_tabs( + #[case] index: usize, + #[case] left_block: T, + #[case] right_block: U, + ) where T: Into + Copy, + U: Into + Copy, { let mut app = App::test_default(); app.error = "Test".into(); @@ -122,8 +128,8 @@ mod tests { } #[test] - fn test_handle_empties_keybindings_table_on_help_button_press_when_keybindings_table_is_already_populated() - { + fn test_handle_empties_keybindings_table_on_help_button_press_when_keybindings_table_is_already_populated( + ) { let mut app = App::test_default(); let keybinding_items = Vec::from(SERVARR_CONTEXT_CLUES) .iter() @@ -254,8 +260,8 @@ mod tests { } #[test] - fn test_populate_keymapping_table_populates_delegated_servarr_context_provider_options_before_global_options() - { + fn test_populate_keymapping_table_populates_delegated_servarr_context_provider_options_before_global_options( + ) { let mut expected_keybinding_items = MOVIE_DETAILS_CONTEXT_CLUES .iter() .map(|(key, desc)| context_clue_to_keybinding_item(key, desc)) diff --git a/src/models/lidarr_models.rs b/src/models/lidarr_models.rs index dcc19df..f17d382 100644 --- a/src/models/lidarr_models.rs +++ b/src/models/lidarr_models.rs @@ -6,6 +6,10 @@ use serde_json::{Number, Value}; use super::{HorizontallyScrollableText, Serdeable}; use crate::serde_enum_from; +#[cfg(test)] +#[path = "lidarr_models_tests.rs"] +mod lidarr_models_tests; + #[derive(Derivative, Serialize, Deserialize, Debug, Default, Clone, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct Artist { diff --git a/src/models/lidarr_models_tests.rs b/src/models/lidarr_models_tests.rs new file mode 100644 index 0000000..212ad87 --- /dev/null +++ b/src/models/lidarr_models_tests.rs @@ -0,0 +1,201 @@ +#[cfg(test)] +mod tests { + use pretty_assertions::{assert_eq, assert_str_eq}; + use serde_json::json; + + use crate::models::{ + lidarr_models::{Artist, ArtistStatistics, ArtistStatus, LidarrSerdeable, Ratings}, + Serdeable, + }; + + #[test] + fn test_artist_status_default() { + assert_eq!(ArtistStatus::default(), ArtistStatus::Continuing); + } + + #[test] + fn test_lidarr_serdeable_from() { + let lidarr_serdeable = LidarrSerdeable::Value(json!({})); + + let serdeable: Serdeable = Serdeable::from(lidarr_serdeable.clone()); + + assert_eq!(serdeable, Serdeable::Lidarr(lidarr_serdeable)); + } + + #[test] + fn test_lidarr_serdeable_from_unit() { + let lidarr_serdeable = LidarrSerdeable::from(()); + + assert_eq!(lidarr_serdeable, LidarrSerdeable::Value(json!({}))); + } + + #[test] + fn test_lidarr_serdeable_from_value() { + let value = json!({"test": "test"}); + + let lidarr_serdeable: LidarrSerdeable = value.clone().into(); + + assert_eq!(lidarr_serdeable, LidarrSerdeable::Value(value)); + } + + #[test] + fn test_lidarr_serdeable_from_artists() { + let artists = vec![Artist { + id: 1, + ..Artist::default() + }]; + + let lidarr_serdeable: LidarrSerdeable = artists.clone().into(); + + assert_eq!(lidarr_serdeable, LidarrSerdeable::Artists(artists)); + } + + #[test] + fn test_artist_deserialization() { + let artist_json = json!({ + "id": 1, + "mbId": "test-mb-id", + "artistName": "Test Artist", + "foreignArtistId": "test-foreign-id", + "status": "continuing", + "overview": "Test overview", + "artistType": "Group", + "disambiguation": "UK Band", + "path": "/music/test-artist", + "qualityProfileId": 1, + "metadataProfileId": 1, + "monitored": true, + "genres": ["Rock", "Alternative"], + "tags": [1, 2], + "added": "2023-01-01T00:00:00Z", + "ratings": { + "votes": 100, + "value": 4.5 + }, + "statistics": { + "albumCount": 5, + "trackFileCount": 50, + "trackCount": 60, + "totalTrackCount": 70, + "sizeOnDisk": 1000000000, + "percentOfTracks": 83.33 + } + }); + + let artist: Artist = serde_json::from_value(artist_json).unwrap(); + + assert_eq!(artist.id, 1); + assert_str_eq!(artist.mb_id, "test-mb-id"); + assert_str_eq!(artist.artist_name.text, "Test Artist"); + assert_str_eq!(artist.foreign_artist_id, "test-foreign-id"); + assert_eq!(artist.status, ArtistStatus::Continuing); + assert_some_eq_x!(&artist.overview, "Test overview"); + assert_some_eq_x!(&artist.artist_type, "Group"); + assert_some_eq_x!(&artist.disambiguation, "UK Band"); + assert_str_eq!(artist.path, "/music/test-artist"); + assert_eq!(artist.quality_profile_id, 1); + assert_eq!(artist.metadata_profile_id, 1); + assert!(artist.monitored); + assert_eq!(artist.genres, vec!["Rock", "Alternative"]); + assert_eq!(artist.tags.len(), 2); + assert_some!(&artist.ratings); + assert_some!(&artist.statistics); + + let ratings = artist.ratings.unwrap(); + assert_eq!(ratings.votes, 100); + assert_eq!(ratings.value, 4.5); + + let stats = artist.statistics.unwrap(); + assert_eq!(stats.album_count, 5); + assert_eq!(stats.track_file_count, 50); + assert_eq!(stats.track_count, 60); + assert_eq!(stats.total_track_count, 70); + assert_eq!(stats.size_on_disk, 1000000000); + assert_eq!(stats.percent_of_tracks, 83.33); + } + + #[test] + fn test_artist_status_deserialization() { + assert_eq!( + serde_json::from_str::("\"continuing\"").unwrap(), + ArtistStatus::Continuing + ); + assert_eq!( + serde_json::from_str::("\"ended\"").unwrap(), + ArtistStatus::Ended + ); + assert_eq!( + serde_json::from_str::("\"deleted\"").unwrap(), + ArtistStatus::Deleted + ); + } + + #[test] + fn test_ratings_equality() { + let ratings1 = Ratings { + votes: 100, + value: 4.5, + }; + let ratings2 = Ratings { + votes: 100, + value: 4.5, + }; + let ratings3 = Ratings { + votes: 50, + value: 3.0, + }; + + assert_eq!(ratings1, ratings2); + assert_ne!(ratings1, ratings3); + } + + #[test] + fn test_artist_statistics_equality() { + let stats1 = ArtistStatistics { + album_count: 5, + track_file_count: 50, + track_count: 60, + total_track_count: 70, + size_on_disk: 1000000000, + percent_of_tracks: 83.33, + }; + let stats2 = ArtistStatistics { + album_count: 5, + track_file_count: 50, + track_count: 60, + total_track_count: 70, + size_on_disk: 1000000000, + percent_of_tracks: 83.33, + }; + let stats3 = ArtistStatistics::default(); + + assert_eq!(stats1, stats2); + assert_ne!(stats1, stats3); + } + + #[test] + fn test_artist_with_optional_fields_none() { + let artist_json = json!({ + "id": 1, + "mbId": "", + "artistName": "Test Artist", + "foreignArtistId": "", + "status": "continuing", + "path": "", + "qualityProfileId": 1, + "metadataProfileId": 1, + "monitored": false, + "genres": [], + "tags": [], + "added": "2023-01-01T00:00:00Z" + }); + + let artist: Artist = serde_json::from_value(artist_json).unwrap(); + + assert_none!(&artist.overview); + assert_none!(&artist.artist_type); + assert_none!(&artist.disambiguation); + assert_none!(&artist.ratings); + assert_none!(&artist.statistics); + } +} diff --git a/src/network/lidarr_network/lidarr_network_tests.rs b/src/network/lidarr_network/lidarr_network_tests.rs new file mode 100644 index 0000000..c56fac5 --- /dev/null +++ b/src/network/lidarr_network/lidarr_network_tests.rs @@ -0,0 +1,72 @@ +#[cfg(test)] +mod tests { + use crate::models::lidarr_models::{Artist, LidarrSerdeable}; + use crate::network::network_tests::test_utils::{MockServarrApi, test_network}; + use crate::network::{NetworkEvent, NetworkResource, lidarr_network::LidarrEvent}; + use pretty_assertions::{assert_eq, assert_str_eq}; + use rstest::rstest; + use serde_json::json; + + #[rstest] + #[case(LidarrEvent::HealthCheck, "/health")] + #[case(LidarrEvent::ListArtists, "/artist")] + fn test_resource(#[case] event: LidarrEvent, #[case] expected_uri: &str) { + assert_str_eq!(event.resource(), expected_uri); + } + + #[test] + fn test_from_lidarr_event() { + assert_eq!( + NetworkEvent::Lidarr(LidarrEvent::HealthCheck), + NetworkEvent::from(LidarrEvent::HealthCheck) + ); + } + + #[tokio::test] + async fn test_handle_get_lidarr_healthcheck_event() { + let (mock, app, _server) = MockServarrApi::get() + .build_for(LidarrEvent::HealthCheck) + .await; + app.lock().await.server_tabs.set_index(2); + let mut network = test_network(&app); + + let _ = network.handle_lidarr_event(LidarrEvent::HealthCheck).await; + + mock.assert_async().await; + } + + #[tokio::test] + async fn test_handle_list_artists_event() { + let artists_json = json!([{ + "id": 1, + "mbId": "test-mb-id", + "artistName": "Test Artist", + "foreignArtistId": "test-foreign-id", + "status": "continuing", + "path": "/music/test-artist", + "qualityProfileId": 1, + "metadataProfileId": 1, + "monitored": true, + "genres": [], + "tags": [], + "added": "2023-01-01T00:00:00Z" + }]); + let response: Vec = serde_json::from_value(artists_json.clone()).unwrap(); + let (mock, app, _server) = MockServarrApi::get() + .returns(artists_json) + .build_for(LidarrEvent::ListArtists) + .await; + app.lock().await.server_tabs.set_index(2); + let mut network = test_network(&app); + + let result = network.handle_lidarr_event(LidarrEvent::ListArtists).await; + + mock.assert_async().await; + + let LidarrSerdeable::Artists(artists) = result.unwrap() else { + panic!("Expected Artists"); + }; + + assert_eq!(artists, response); + } +} diff --git a/src/network/lidarr_network/mod.rs b/src/network/lidarr_network/mod.rs index 36119b0..74c32a1 100644 --- a/src/network/lidarr_network/mod.rs +++ b/src/network/lidarr_network/mod.rs @@ -7,6 +7,10 @@ use crate::{ network::RequestMethod, }; +#[cfg(test)] +#[path = "lidarr_network_tests.rs"] +mod lidarr_network_tests; + #[derive(Debug, Eq, PartialEq, Clone)] pub enum LidarrEvent { HealthCheck, diff --git a/src/network/network_tests.rs b/src/network/network_tests.rs index 7b83b9c..7e7abc2 100644 --- a/src/network/network_tests.rs +++ b/src/network/network_tests.rs @@ -810,11 +810,16 @@ pub(in crate::network) mod test_utils { network_event: E, ) -> (Mock, Arc>>, ServerGuard) where - E: Into + NetworkResource, + E: Into + NetworkResource + Clone, { let resource = network_event.resource(); + let network_event_clone: NetworkEvent = network_event.clone().into(); + let api_version = match &network_event_clone { + NetworkEvent::Lidarr(_) => "v1", + _ => "v3", + }; let mut server = Server::new_async().await; - let mut uri = format!("/api/v3{resource}"); + let mut uri = format!("/api/{api_version}{resource}"); if let Some(path) = &self.path { uri = format!("{uri}{path}"); @@ -853,9 +858,10 @@ pub(in crate::network) mod test_utils { ..ServarrConfig::default() }; - match network_event.into() { + match network_event_clone { NetworkEvent::Radarr(_) => app.server_tabs.tabs[0].config = Some(servarr_config), NetworkEvent::Sonarr(_) => app.server_tabs.tabs[1].config = Some(servarr_config), + NetworkEvent::Lidarr(_) => app.server_tabs.tabs[2].config = Some(servarr_config), } let app_arc = Arc::new(Mutex::new(app)); diff --git a/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__radarr_ui_renders_library_tab.snap b/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__radarr_ui_renders_library_tab.snap index 8f537b7..22a7c1a 100644 --- a/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__radarr_ui_renders_library_tab.snap +++ b/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__radarr_ui_renders_library_tab.snap @@ -3,7 +3,7 @@ source: src/ui/ui_tests.rs expression: output --- ╭ Managarr - A Servarr management TUI ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ -│ Radarr │ Sonarr to open help│ +│ Radarr │ Sonarr │ Lidarr to open help│ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭ Stats ──────────────────────────────────────────────────────────────╮╭ Downloads ─────────────────────────────────────────────────────────╮╭──────────────────╮ │Radarr Version: 1.2.3.4 ││Test Download Title ││ ⠀⣠⣶⢶⣶⣤⣀⠀⠀⠀⠀⠀⠀⠀⠀ │ diff --git a/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__radarr_ui_renders_library_tab_error_popup.snap b/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__radarr_ui_renders_library_tab_error_popup.snap index 72a40f2..15eca48 100644 --- a/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__radarr_ui_renders_library_tab_error_popup.snap +++ b/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__radarr_ui_renders_library_tab_error_popup.snap @@ -3,7 +3,7 @@ source: src/ui/ui_tests.rs expression: output --- ╭ Managarr - A Servarr management TUI ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ -│ Radarr │ Sonarr to open help│ +│ Radarr │ Sonarr │ Lidarr to open help│ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭ Stats ──────────────────────────────────────────────────────────────╮╭ Downloads ─────────────────────────────────────────────────────────╮╭──────────────────╮ │Radarr Version: 1.2.3.4 ╭ Keybindings ──────────────────────────────────────────────────────────────────────────╮ ││ ⠀⣠⣶⢶⣶⣤⣀⠀⠀⠀⠀⠀⠀⠀⠀ │ diff --git a/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__radarr_ui_renders_library_tab_with_error.snap b/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__radarr_ui_renders_library_tab_with_error.snap index 17602d3..dad6259 100644 --- a/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__radarr_ui_renders_library_tab_with_error.snap +++ b/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__radarr_ui_renders_library_tab_with_error.snap @@ -3,7 +3,7 @@ source: src/ui/ui_tests.rs expression: output --- ╭ Managarr - A Servarr management TUI ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ -│ Radarr │ Sonarr to open help│ +│ Radarr │ Sonarr │ Lidarr to open help│ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭ Error | to close ─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Some error │ -- 2.52.0 From bc3aeefa6e0dbdcb86995f86f18a258c56937237 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Mon, 5 Jan 2026 13:10:30 -0700 Subject: [PATCH 03/61] feat: TUI support for Lidarr library --- src/app/app_tests.rs | 2 + src/app/context_clues.rs | 2 + src/app/lidarr/lidarr_context_clues.rs | 25 ++ src/app/lidarr/lidarr_context_clues_tests.rs | 92 ++++++ src/app/lidarr/lidarr_tests.rs | 28 ++ src/app/lidarr/mod.rs | 97 +++++++ src/app/mod.rs | 6 +- .../library/library_handler_tests.rs | 272 ++++++++++++++++++ src/handlers/lidarr_handlers/library/mod.rs | 211 ++++++++++++++ .../lidarr_handlers/lidarr_handler_tests.rs | 15 + src/handlers/lidarr_handlers/mod.rs | 102 +++++++ src/handlers/mod.rs | 8 + src/models/lidarr_models.rs | 104 ++++++- src/models/lidarr_models_tests.rs | 1 - src/models/servarr_data/lidarr/lidarr_data.rs | 115 +++++++- src/models/stateful_table.rs | 18 +- src/models/stateful_table_tests.rs | 41 +++ .../library/lidarr_library_network_tests.rs | 44 +++ src/network/lidarr_network/library/mod.rs | 36 +++ .../lidarr_network/lidarr_network_tests.rs | 60 +--- src/network/lidarr_network/mod.rs | 77 ++--- .../system/lidarr_system_network_tests.rs | 246 ++++++++++++++++ src/network/lidarr_network/system/mod.rs | 164 +++++++++++ .../sonarr_network/library/series/mod.rs | 2 +- src/ui/lidarr_ui/library/library_ui_tests.rs | 20 ++ src/ui/lidarr_ui/library/mod.rs | 185 ++++++++++++ src/ui/lidarr_ui/lidarr_ui_tests.rs | 16 ++ src/ui/lidarr_ui/mod.rs | 209 ++++++++++++++ src/ui/mod.rs | 6 + 29 files changed, 2113 insertions(+), 91 deletions(-) create mode 100644 src/app/lidarr/lidarr_context_clues.rs create mode 100644 src/app/lidarr/lidarr_context_clues_tests.rs create mode 100644 src/app/lidarr/lidarr_tests.rs create mode 100644 src/app/lidarr/mod.rs create mode 100644 src/handlers/lidarr_handlers/library/library_handler_tests.rs create mode 100644 src/handlers/lidarr_handlers/library/mod.rs create mode 100644 src/handlers/lidarr_handlers/lidarr_handler_tests.rs create mode 100644 src/handlers/lidarr_handlers/mod.rs create mode 100644 src/network/lidarr_network/library/lidarr_library_network_tests.rs create mode 100644 src/network/lidarr_network/library/mod.rs create mode 100644 src/network/lidarr_network/system/lidarr_system_network_tests.rs create mode 100644 src/network/lidarr_network/system/mod.rs create mode 100644 src/ui/lidarr_ui/library/library_ui_tests.rs create mode 100644 src/ui/lidarr_ui/library/mod.rs create mode 100644 src/ui/lidarr_ui/lidarr_ui_tests.rs create mode 100644 src/ui/lidarr_ui/mod.rs diff --git a/src/app/app_tests.rs b/src/app/app_tests.rs index 6d9b1f8..f1a3d5d 100644 --- a/src/app/app_tests.rs +++ b/src/app/app_tests.rs @@ -8,6 +8,7 @@ mod tests { use tokio::sync::mpsc; use crate::app::{App, AppConfig, Data, ServarrConfig, interpolate_env_vars}; + use crate::models::servarr_data::lidarr::lidarr_data::LidarrData; 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}; @@ -185,6 +186,7 @@ mod tests { ..SonarrData::default() }; let data = Data { + lidarr_data: LidarrData::default(), radarr_data, sonarr_data, }; diff --git a/src/app/context_clues.rs b/src/app/context_clues.rs index f58f901..a7f0f4c 100644 --- a/src/app/context_clues.rs +++ b/src/app/context_clues.rs @@ -1,5 +1,6 @@ use crate::app::App; use crate::app::key_binding::{DEFAULT_KEYBINDINGS, KeyBinding}; +use crate::app::lidarr::lidarr_context_clues::LidarrContextClueProvider; use crate::app::radarr::radarr_context_clues::RadarrContextClueProvider; use crate::app::sonarr::sonarr_context_clues::SonarrContextClueProvider; use crate::models::Route; @@ -21,6 +22,7 @@ impl ContextClueProvider for ServarrContextClueProvider { match app.get_current_route() { Route::Radarr(_, _) => RadarrContextClueProvider::get_context_clues(app), Route::Sonarr(_, _) => SonarrContextClueProvider::get_context_clues(app), + Route::Lidarr(_, _) => LidarrContextClueProvider::get_context_clues(app), _ => None, } } diff --git a/src/app/lidarr/lidarr_context_clues.rs b/src/app/lidarr/lidarr_context_clues.rs new file mode 100644 index 0000000..a2cdabe --- /dev/null +++ b/src/app/lidarr/lidarr_context_clues.rs @@ -0,0 +1,25 @@ +use crate::app::App; +use crate::app::context_clues::{ContextClue, ContextClueProvider}; +use crate::models::Route; + +#[cfg(test)] +#[path = "lidarr_context_clues_tests.rs"] +mod lidarr_context_clues_tests; + +pub(in crate::app) struct LidarrContextClueProvider; + +impl ContextClueProvider for LidarrContextClueProvider { + fn get_context_clues(app: &mut App<'_>) -> Option<&'static [ContextClue]> { + let Route::Lidarr(active_lidarr_block, _context_option) = app.get_current_route() else { + panic!("LidarrContextClueProvider::get_context_clues called with non-Lidarr route"); + }; + + match active_lidarr_block { + _ => app + .data + .lidarr_data + .main_tabs + .get_active_route_contextual_help(), + } + } +} diff --git a/src/app/lidarr/lidarr_context_clues_tests.rs b/src/app/lidarr/lidarr_context_clues_tests.rs new file mode 100644 index 0000000..6f7f177 --- /dev/null +++ b/src/app/lidarr/lidarr_context_clues_tests.rs @@ -0,0 +1,92 @@ +#[cfg(test)] +mod tests { + use crate::app::context_clues::ContextClueProvider; + use crate::app::key_binding::DEFAULT_KEYBINDINGS; + use crate::app::lidarr::lidarr_context_clues::LidarrContextClueProvider; + use crate::app::App; + use crate::models::servarr_data::lidarr::lidarr_data::{ + ActiveLidarrBlock, ARTISTS_CONTEXT_CLUES, + }; + use crate::models::servarr_data::radarr::radarr_data::ActiveRadarrBlock; + + #[test] + fn test_artists_context_clues() { + let mut artists_context_clues_iter = ARTISTS_CONTEXT_CLUES.iter(); + + assert_some_eq_x!( + artists_context_clues_iter.next(), + &(DEFAULT_KEYBINDINGS.sort, DEFAULT_KEYBINDINGS.sort.desc) + ); + assert_some_eq_x!( + artists_context_clues_iter.next(), + &(DEFAULT_KEYBINDINGS.search, DEFAULT_KEYBINDINGS.search.desc) + ); + assert_some_eq_x!( + artists_context_clues_iter.next(), + &(DEFAULT_KEYBINDINGS.filter, DEFAULT_KEYBINDINGS.filter.desc) + ); + assert_some_eq_x!( + artists_context_clues_iter.next(), + &( + DEFAULT_KEYBINDINGS.refresh, + DEFAULT_KEYBINDINGS.refresh.desc + ) + ); + assert_some_eq_x!( + artists_context_clues_iter.next(), + &(DEFAULT_KEYBINDINGS.esc, "cancel filter") + ); + assert_none!(artists_context_clues_iter.next()); + } + + #[test] + #[should_panic( + expected = "LidarrContextClueProvider::get_context_clues called with non-Lidarr route" + )] + fn test_lidarr_context_clue_provider_get_context_clues_non_lidarr_route() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveRadarrBlock::default().into()); + + LidarrContextClueProvider::get_context_clues(&mut app); + } + + #[test] + fn test_lidarr_context_clue_provider_artists_block() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveLidarrBlock::Artists.into()); + + let context_clues = LidarrContextClueProvider::get_context_clues(&mut app); + + assert_some_eq_x!(context_clues, &ARTISTS_CONTEXT_CLUES); + } + + #[test] + fn test_lidarr_context_clue_provider_artists_sort_prompt_block() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveLidarrBlock::ArtistsSortPrompt.into()); + + let context_clues = LidarrContextClueProvider::get_context_clues(&mut app); + + assert_some_eq_x!(context_clues, &ARTISTS_CONTEXT_CLUES); + } + + #[test] + fn test_lidarr_context_clue_provider_search_artists_block() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveLidarrBlock::SearchArtists.into()); + + let context_clues = LidarrContextClueProvider::get_context_clues(&mut app); + + assert_some_eq_x!(context_clues, &ARTISTS_CONTEXT_CLUES); + } + + #[test] + fn test_lidarr_context_clue_provider_filter_artists_block() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveLidarrBlock::FilterArtists.into()); + + let context_clues = LidarrContextClueProvider::get_context_clues(&mut app); + + assert_some_eq_x!(context_clues, &ARTISTS_CONTEXT_CLUES); + } +} diff --git a/src/app/lidarr/lidarr_tests.rs b/src/app/lidarr/lidarr_tests.rs new file mode 100644 index 0000000..75b282e --- /dev/null +++ b/src/app/lidarr/lidarr_tests.rs @@ -0,0 +1,28 @@ +#[cfg(test)] +mod tests { + use crate::app::App; + use crate::models::servarr_data::lidarr::lidarr_data::ActiveLidarrBlock; + use crate::network::NetworkEvent; + use crate::network::lidarr_network::LidarrEvent; + use tokio::sync::mpsc; + + #[tokio::test] + async fn test_dispatch_by_lidarr_block_artists() { + let (tx, mut rx) = mpsc::channel::(500); + let mut app = App::test_default(); + app.network_tx = Some(tx); + + app.dispatch_by_lidarr_block(&ActiveLidarrBlock::Artists).await; + + assert_eq!( + rx.recv().await.unwrap(), + LidarrEvent::GetQualityProfiles.into() + ); + assert_eq!( + rx.recv().await.unwrap(), + LidarrEvent::GetMetadataProfiles.into() + ); + assert_eq!(rx.recv().await.unwrap(), LidarrEvent::GetTags.into()); + assert_eq!(rx.recv().await.unwrap(), LidarrEvent::ListArtists.into()); + } +} diff --git a/src/app/lidarr/mod.rs b/src/app/lidarr/mod.rs new file mode 100644 index 0000000..4caaaea --- /dev/null +++ b/src/app/lidarr/mod.rs @@ -0,0 +1,97 @@ +use crate::{ + models::servarr_data::lidarr::lidarr_data::ActiveLidarrBlock, + network::lidarr_network::LidarrEvent, +}; + +use super::App; + +pub(in crate::app) mod lidarr_context_clues; + +#[cfg(test)] +#[path = "lidarr_tests.rs"] +mod lidarr_tests; + +impl App<'_> { + pub(super) async fn dispatch_by_lidarr_block(&mut self, active_lidarr_block: &ActiveLidarrBlock) { + match active_lidarr_block { + ActiveLidarrBlock::Artists => { + self + .dispatch_network_event(LidarrEvent::GetQualityProfiles.into()) + .await; + self + .dispatch_network_event(LidarrEvent::GetMetadataProfiles.into()) + .await; + self + .dispatch_network_event(LidarrEvent::GetTags.into()) + .await; + self + .dispatch_network_event(LidarrEvent::ListArtists.into()) + .await; + } + _ => (), + } + + self.check_for_lidarr_prompt_action().await; + self.reset_tick_count(); + } + + async fn check_for_lidarr_prompt_action(&mut self) { + if self.data.lidarr_data.prompt_confirm { + self.data.lidarr_data.prompt_confirm = false; + if let Some(lidarr_event) = self.data.lidarr_data.prompt_confirm_action.take() { + self.dispatch_network_event(lidarr_event.into()).await; + self.should_refresh = true; + } + } + } + + pub(super) async fn lidarr_on_tick(&mut self, active_lidarr_block: ActiveLidarrBlock) { + if self.is_first_render { + self.refresh_lidarr_metadata().await; + self.dispatch_by_lidarr_block(&active_lidarr_block).await; + self.is_first_render = false; + return; + } + + if self.should_refresh { + self.dispatch_by_lidarr_block(&active_lidarr_block).await; + self.refresh_lidarr_metadata().await; + } + + if self.is_routing { + if !self.should_refresh { + self.cancellation_token.cancel(); + } else { + self.dispatch_by_lidarr_block(&active_lidarr_block).await; + } + } + + if self.tick_count.is_multiple_of(self.tick_until_poll) { + self.refresh_lidarr_metadata().await; + } + } + + async fn refresh_lidarr_metadata(&mut self) { + self + .dispatch_network_event(LidarrEvent::GetQualityProfiles.into()) + .await; + self + .dispatch_network_event(LidarrEvent::GetMetadataProfiles.into()) + .await; + self + .dispatch_network_event(LidarrEvent::GetTags.into()) + .await; + self + .dispatch_network_event(LidarrEvent::GetRootFolders.into()) + .await; + self + .dispatch_network_event(LidarrEvent::GetDownloads(500).into()) + .await; + self + .dispatch_network_event(LidarrEvent::GetDiskSpace.into()) + .await; + self + .dispatch_network_event(LidarrEvent::GetStatus.into()) + .await; + } +} diff --git a/src/app/mod.rs b/src/app/mod.rs index 68edf1f..c40e0fb 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -13,7 +13,7 @@ use tokio_util::sync::CancellationToken; use veil::Redact; use crate::cli::Command; -use crate::models::servarr_data::lidarr::lidarr_data::ActiveLidarrBlock; +use crate::models::servarr_data::lidarr::lidarr_data::{ActiveLidarrBlock, LidarrData}; use crate::models::servarr_data::radarr::radarr_data::{ActiveRadarrBlock, RadarrData}; use crate::models::servarr_data::sonarr::sonarr_data::{ActiveSonarrBlock, SonarrData}; use crate::models::servarr_models::KeybindingItem; @@ -26,6 +26,7 @@ mod app_tests; pub mod context_clues; pub mod key_binding; mod key_binding_tests; +pub mod lidarr; pub mod radarr; pub mod sonarr; @@ -197,6 +198,7 @@ impl App<'_> { match self.get_current_route() { Route::Radarr(active_radarr_block, _) => self.radarr_on_tick(active_radarr_block).await, Route::Sonarr(active_sonarr_block, _) => self.sonarr_on_tick(active_sonarr_block).await, + Route::Lidarr(active_lidarr_block, _) => self.lidarr_on_tick(active_lidarr_block).await, _ => (), } @@ -299,6 +301,7 @@ impl App<'_> { pub fn test_default_fully_populated() -> Self { App { data: Data { + lidarr_data: LidarrData::test_default_fully_populated(), radarr_data: RadarrData::test_default_fully_populated(), sonarr_data: SonarrData::test_default_fully_populated(), }, @@ -329,6 +332,7 @@ impl App<'_> { #[derive(Default)] pub struct Data<'a> { + pub lidarr_data: LidarrData<'a>, pub radarr_data: RadarrData<'a>, pub sonarr_data: SonarrData<'a>, } diff --git a/src/handlers/lidarr_handlers/library/library_handler_tests.rs b/src/handlers/lidarr_handlers/library/library_handler_tests.rs new file mode 100644 index 0000000..da5784b --- /dev/null +++ b/src/handlers/lidarr_handlers/library/library_handler_tests.rs @@ -0,0 +1,272 @@ +#[cfg(test)] +mod tests { + use std::cmp::Ordering; + + use pretty_assertions::assert_str_eq; + use serde_json::Number; + use strum::IntoEnumIterator; + + use crate::handlers::lidarr_handlers::library::{LibraryHandler, artists_sorting_options}; + use crate::handlers::KeyEventHandler; + use crate::models::lidarr_models::{Artist, ArtistStatistics, ArtistStatus}; + use crate::models::servarr_data::lidarr::lidarr_data::{ActiveLidarrBlock, LIBRARY_BLOCKS}; + + #[test] + fn test_library_handler_accepts() { + for lidarr_block in ActiveLidarrBlock::iter() { + if LIBRARY_BLOCKS.contains(&lidarr_block) { + assert!(LibraryHandler::accepts(lidarr_block)); + } else { + assert!(!LibraryHandler::accepts(lidarr_block)); + } + } + } + + #[test] + fn test_artists_sorting_options_name() { + let expected_cmp_fn: fn(&Artist, &Artist) -> Ordering = |a, b| { + a.artist_name + .text + .to_lowercase() + .cmp(&b.artist_name.text.to_lowercase()) + }; + let mut expected_artists_vec = artists_vec(); + expected_artists_vec.sort_by(expected_cmp_fn); + + let sort_option = artists_sorting_options()[0].clone(); + let mut sorted_artists_vec = artists_vec(); + sorted_artists_vec.sort_by(sort_option.cmp_fn.unwrap()); + + assert_eq!(sorted_artists_vec, expected_artists_vec); + assert_str_eq!(sort_option.name, "Name"); + } + + #[test] + fn test_artists_sorting_options_type() { + let expected_cmp_fn: fn(&Artist, &Artist) -> Ordering = |a, b| { + a.artist_type + .as_ref() + .unwrap_or(&String::new()) + .to_lowercase() + .cmp( + &b.artist_type + .as_ref() + .unwrap_or(&String::new()) + .to_lowercase(), + ) + }; + let mut expected_artists_vec = artists_vec(); + expected_artists_vec.sort_by(expected_cmp_fn); + + let sort_option = artists_sorting_options()[1].clone(); + let mut sorted_artists_vec = artists_vec(); + sorted_artists_vec.sort_by(sort_option.cmp_fn.unwrap()); + + assert_eq!(sorted_artists_vec, expected_artists_vec); + assert_str_eq!(sort_option.name, "Type"); + } + + #[test] + fn test_artists_sorting_options_status() { + let expected_cmp_fn: fn(&Artist, &Artist) -> Ordering = |a, b| { + a.status + .to_string() + .to_lowercase() + .cmp(&b.status.to_string().to_lowercase()) + }; + let mut expected_artists_vec = artists_vec(); + expected_artists_vec.sort_by(expected_cmp_fn); + + let sort_option = artists_sorting_options()[2].clone(); + let mut sorted_artists_vec = artists_vec(); + sorted_artists_vec.sort_by(sort_option.cmp_fn.unwrap()); + + assert_eq!(sorted_artists_vec, expected_artists_vec); + assert_str_eq!(sort_option.name, "Status"); + } + + #[test] + fn test_artists_sorting_options_quality_profile() { + let expected_cmp_fn: fn(&Artist, &Artist) -> Ordering = + |a, b| a.quality_profile_id.cmp(&b.quality_profile_id); + let mut expected_artists_vec = artists_vec(); + expected_artists_vec.sort_by(expected_cmp_fn); + + let sort_option = artists_sorting_options()[3].clone(); + let mut sorted_artists_vec = artists_vec(); + sorted_artists_vec.sort_by(sort_option.cmp_fn.unwrap()); + + assert_eq!(sorted_artists_vec, expected_artists_vec); + assert_str_eq!(sort_option.name, "Quality Profile"); + } + + #[test] + fn test_artists_sorting_options_metadata_profile() { + let expected_cmp_fn: fn(&Artist, &Artist) -> Ordering = + |a, b| a.metadata_profile_id.cmp(&b.metadata_profile_id); + let mut expected_artists_vec = artists_vec(); + expected_artists_vec.sort_by(expected_cmp_fn); + + let sort_option = artists_sorting_options()[4].clone(); + let mut sorted_artists_vec = artists_vec(); + sorted_artists_vec.sort_by(sort_option.cmp_fn.unwrap()); + + assert_eq!(sorted_artists_vec, expected_artists_vec); + assert_str_eq!(sort_option.name, "Metadata Profile"); + } + + #[test] + fn test_artists_sorting_options_albums() { + let expected_cmp_fn: fn(&Artist, &Artist) -> Ordering = |a, b| { + a.statistics + .as_ref() + .map_or(0, |stats| stats.album_count) + .cmp(&b.statistics.as_ref().map_or(0, |stats| stats.album_count)) + }; + let mut expected_artists_vec = artists_vec(); + expected_artists_vec.sort_by(expected_cmp_fn); + + let sort_option = artists_sorting_options()[5].clone(); + let mut sorted_artists_vec = artists_vec(); + sorted_artists_vec.sort_by(sort_option.cmp_fn.unwrap()); + + assert_eq!(sorted_artists_vec, expected_artists_vec); + assert_str_eq!(sort_option.name, "Albums"); + } + + #[test] + fn test_artists_sorting_options_tracks() { + let expected_cmp_fn: fn(&Artist, &Artist) -> Ordering = |a, b| { + a.statistics + .as_ref() + .map_or(0, |stats| stats.track_count) + .cmp(&b.statistics.as_ref().map_or(0, |stats| stats.track_count)) + }; + let mut expected_artists_vec = artists_vec(); + expected_artists_vec.sort_by(expected_cmp_fn); + + let sort_option = artists_sorting_options()[6].clone(); + let mut sorted_artists_vec = artists_vec(); + sorted_artists_vec.sort_by(sort_option.cmp_fn.unwrap()); + + assert_eq!(sorted_artists_vec, expected_artists_vec); + assert_str_eq!(sort_option.name, "Tracks"); + } + + #[test] + fn test_artists_sorting_options_size() { + let expected_cmp_fn: fn(&Artist, &Artist) -> Ordering = |a, b| { + a.statistics + .as_ref() + .map_or(0, |stats| stats.size_on_disk) + .cmp(&b.statistics.as_ref().map_or(0, |stats| stats.size_on_disk)) + }; + let mut expected_artists_vec = artists_vec(); + expected_artists_vec.sort_by(expected_cmp_fn); + + let sort_option = artists_sorting_options()[7].clone(); + let mut sorted_artists_vec = artists_vec(); + sorted_artists_vec.sort_by(sort_option.cmp_fn.unwrap()); + + assert_eq!(sorted_artists_vec, expected_artists_vec); + assert_str_eq!(sort_option.name, "Size"); + } + + #[test] + fn test_artists_sorting_options_monitored() { + let expected_cmp_fn: fn(&Artist, &Artist) -> Ordering = |a, b| a.monitored.cmp(&b.monitored); + let mut expected_artists_vec = artists_vec(); + expected_artists_vec.sort_by(expected_cmp_fn); + + let sort_option = artists_sorting_options()[8].clone(); + let mut sorted_artists_vec = artists_vec(); + sorted_artists_vec.sort_by(sort_option.cmp_fn.unwrap()); + + assert_eq!(sorted_artists_vec, expected_artists_vec); + assert_str_eq!(sort_option.name, "Monitored"); + } + + #[test] + fn test_artists_sorting_options_tags() { + let expected_cmp_fn: fn(&Artist, &Artist) -> Ordering = |a, b| { + let a_str = a + .tags + .iter() + .map(|tag| tag.as_i64().unwrap().to_string()) + .collect::>() + .join(","); + let b_str = b + .tags + .iter() + .map(|tag| tag.as_i64().unwrap().to_string()) + .collect::>() + .join(","); + a_str.cmp(&b_str) + }; + let mut expected_artists_vec = artists_vec(); + expected_artists_vec.sort_by(expected_cmp_fn); + + let sort_option = artists_sorting_options()[9].clone(); + let mut sorted_artists_vec = artists_vec(); + sorted_artists_vec.sort_by(sort_option.cmp_fn.unwrap()); + + assert_eq!(sorted_artists_vec, expected_artists_vec); + assert_str_eq!(sort_option.name, "Tags"); + } + + fn artists_vec() -> Vec { + vec![ + Artist { + id: 3, + artist_name: "Test Artist 1".into(), + artist_type: Some("Group".to_owned()), + status: ArtistStatus::Ended, + quality_profile_id: 1, + metadata_profile_id: 1, + monitored: false, + tags: vec![Number::from(1), Number::from(2)], + statistics: Some(ArtistStatistics { + album_count: 5, + track_count: 50, + size_on_disk: 789, + ..ArtistStatistics::default() + }), + ..Artist::default() + }, + Artist { + id: 2, + artist_name: "Test Artist 2".into(), + artist_type: Some("Solo".to_owned()), + status: ArtistStatus::Continuing, + quality_profile_id: 2, + metadata_profile_id: 2, + monitored: false, + tags: vec![Number::from(1), Number::from(3)], + statistics: Some(ArtistStatistics { + album_count: 10, + track_count: 100, + size_on_disk: 456, + ..ArtistStatistics::default() + }), + ..Artist::default() + }, + Artist { + id: 1, + artist_name: "Test Artist 3".into(), + artist_type: None, + status: ArtistStatus::Deleted, + quality_profile_id: 3, + metadata_profile_id: 3, + monitored: true, + tags: vec![Number::from(2), Number::from(3)], + statistics: Some(ArtistStatistics { + album_count: 3, + track_count: 30, + size_on_disk: 123, + ..ArtistStatistics::default() + }), + ..Artist::default() + }, + ] + } +} diff --git a/src/handlers/lidarr_handlers/library/mod.rs b/src/handlers/lidarr_handlers/library/mod.rs new file mode 100644 index 0000000..348fc49 --- /dev/null +++ b/src/handlers/lidarr_handlers/library/mod.rs @@ -0,0 +1,211 @@ +use crate::{ + app::App, + event::Key, + handlers::{KeyEventHandler, handle_clear_errors}, + matches_key, + models::{ + lidarr_models::Artist, + servarr_data::lidarr::lidarr_data::{ActiveLidarrBlock, LIBRARY_BLOCKS}, + stateful_table::SortOption, + }, +}; + +use super::handle_change_tab_left_right_keys; +use crate::handlers::table_handler::{TableHandlingConfig, handle_table}; + +#[cfg(test)] +#[path = "library_handler_tests.rs"] +mod library_handler_tests; + +pub(super) struct LibraryHandler<'a, 'b> { + key: Key, + app: &'a mut App<'b>, + active_lidarr_block: ActiveLidarrBlock, + _context: Option, +} + +impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveLidarrBlock> for LibraryHandler<'a, 'b> { + fn handle(&mut self) { + let artists_table_handling_config = + TableHandlingConfig::new(ActiveLidarrBlock::Artists.into()) + .sorting_block(ActiveLidarrBlock::ArtistsSortPrompt.into()) + .sort_options(artists_sorting_options()) + .searching_block(ActiveLidarrBlock::SearchArtists.into()) + .search_error_block(ActiveLidarrBlock::SearchArtistsError.into()) + .search_field_fn(|artist| &artist.artist_name.text) + .filtering_block(ActiveLidarrBlock::FilterArtists.into()) + .filter_error_block(ActiveLidarrBlock::FilterArtistsError.into()) + .filter_field_fn(|artist| &artist.artist_name.text); + + if !handle_table( + self, + |app| &mut app.data.lidarr_data.artists, + artists_table_handling_config, + ) { + self.handle_key_event(); + } + } + + fn accepts(active_block: ActiveLidarrBlock) -> bool { + LIBRARY_BLOCKS.contains(&active_block) + } + + fn ignore_special_keys(&self) -> bool { + self.app.ignore_special_keys_for_textbox_input + } + + fn new( + key: Key, + app: &'a mut App<'b>, + active_block: ActiveLidarrBlock, + context: Option, + ) -> LibraryHandler<'a, 'b> { + LibraryHandler { + key, + app, + active_lidarr_block: active_block, + _context: context, + } + } + + fn get_key(&self) -> Key { + self.key + } + + fn is_ready(&self) -> bool { + !self.app.is_loading && !self.app.data.lidarr_data.artists.is_empty() + } + + fn handle_scroll_up(&mut self) {} + + fn handle_scroll_down(&mut self) {} + + fn handle_home(&mut self) {} + + fn handle_end(&mut self) {} + + fn handle_delete(&mut self) {} + + fn handle_left_right_action(&mut self) { + if self.active_lidarr_block == ActiveLidarrBlock::Artists { + handle_change_tab_left_right_keys(self.app, self.key); + } + } + + fn handle_submit(&mut self) {} + + fn handle_esc(&mut self) { + handle_clear_errors(self.app); + } + + fn handle_char_key_event(&mut self) { + let key = self.key; + if self.active_lidarr_block == ActiveLidarrBlock::Artists && matches_key!(refresh, key) { + self.app.should_refresh = true; + } + } + + fn app_mut(&mut self) -> &mut App<'b> { + self.app + } + + fn current_route(&self) -> crate::models::Route { + self.app.get_current_route() + } +} + +fn artists_sorting_options() -> Vec> { + vec![ + SortOption { + name: "Name", + cmp_fn: Some(|a, b| { + a.artist_name + .text + .to_lowercase() + .cmp(&b.artist_name.text.to_lowercase()) + }), + }, + SortOption { + name: "Type", + cmp_fn: Some(|a, b| { + a.artist_type + .as_ref() + .unwrap_or(&String::new()) + .to_lowercase() + .cmp( + &b.artist_type + .as_ref() + .unwrap_or(&String::new()) + .to_lowercase(), + ) + }), + }, + SortOption { + name: "Status", + cmp_fn: Some(|a, b| { + a.status + .to_string() + .to_lowercase() + .cmp(&b.status.to_string().to_lowercase()) + }), + }, + SortOption { + name: "Quality Profile", + cmp_fn: Some(|a, b| a.quality_profile_id.cmp(&b.quality_profile_id)), + }, + SortOption { + name: "Metadata Profile", + cmp_fn: Some(|a, b| a.metadata_profile_id.cmp(&b.metadata_profile_id)), + }, + SortOption { + name: "Albums", + cmp_fn: Some(|a, b| { + a.statistics + .as_ref() + .map_or(0, |stats| stats.album_count) + .cmp(&b.statistics.as_ref().map_or(0, |stats| stats.album_count)) + }), + }, + SortOption { + name: "Tracks", + cmp_fn: Some(|a, b| { + a.statistics + .as_ref() + .map_or(0, |stats| stats.track_count) + .cmp(&b.statistics.as_ref().map_or(0, |stats| stats.track_count)) + }), + }, + SortOption { + name: "Size", + cmp_fn: Some(|a, b| { + a.statistics + .as_ref() + .map_or(0, |stats| stats.size_on_disk) + .cmp(&b.statistics.as_ref().map_or(0, |stats| stats.size_on_disk)) + }), + }, + SortOption { + name: "Monitored", + cmp_fn: Some(|a, b| a.monitored.cmp(&b.monitored)), + }, + SortOption { + name: "Tags", + cmp_fn: Some(|a, b| { + let a_str = a + .tags + .iter() + .map(|tag| tag.as_i64().unwrap().to_string()) + .collect::>() + .join(","); + let b_str = b + .tags + .iter() + .map(|tag| tag.as_i64().unwrap().to_string()) + .collect::>() + .join(","); + + a_str.cmp(&b_str) + }), + }, + ] +} diff --git a/src/handlers/lidarr_handlers/lidarr_handler_tests.rs b/src/handlers/lidarr_handlers/lidarr_handler_tests.rs new file mode 100644 index 0000000..34af245 --- /dev/null +++ b/src/handlers/lidarr_handlers/lidarr_handler_tests.rs @@ -0,0 +1,15 @@ +#[cfg(test)] +mod tests { + use strum::IntoEnumIterator; + + use crate::handlers::KeyEventHandler; + use crate::handlers::lidarr_handlers::LidarrHandler; + use crate::models::servarr_data::lidarr::lidarr_data::ActiveLidarrBlock; + + #[test] + fn test_lidarr_handler_accepts() { + for lidarr_block in ActiveLidarrBlock::iter() { + assert!(LidarrHandler::accepts(lidarr_block)); + } + } +} diff --git a/src/handlers/lidarr_handlers/mod.rs b/src/handlers/lidarr_handlers/mod.rs new file mode 100644 index 0000000..0002e73 --- /dev/null +++ b/src/handlers/lidarr_handlers/mod.rs @@ -0,0 +1,102 @@ +use library::LibraryHandler; + +use crate::{ + app::App, event::Key, matches_key, models::servarr_data::lidarr::lidarr_data::ActiveLidarrBlock, +}; + +use super::KeyEventHandler; + +mod library; + +#[cfg(test)] +#[path = "lidarr_handler_tests.rs"] +mod lidarr_handler_tests; + +pub(super) struct LidarrHandler<'a, 'b> { + key: Key, + app: &'a mut App<'b>, + active_lidarr_block: ActiveLidarrBlock, + context: Option, +} + +impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveLidarrBlock> for LidarrHandler<'a, 'b> { + fn handle(&mut self) { + match self.active_lidarr_block { + _ if LibraryHandler::accepts(self.active_lidarr_block) => { + LibraryHandler::new(self.key, self.app, self.active_lidarr_block, self.context).handle(); + } + _ => self.handle_key_event(), + } + } + + fn accepts(_active_block: ActiveLidarrBlock) -> bool { + true + } + + fn ignore_special_keys(&self) -> bool { + self.app.ignore_special_keys_for_textbox_input + } + + fn new( + key: Key, + app: &'a mut App<'b>, + active_block: ActiveLidarrBlock, + context: Option, + ) -> LidarrHandler<'a, 'b> { + LidarrHandler { + key, + app, + active_lidarr_block: active_block, + context, + } + } + + fn get_key(&self) -> Key { + self.key + } + + fn is_ready(&self) -> bool { + true + } + + fn handle_scroll_up(&mut self) {} + + fn handle_scroll_down(&mut self) {} + + fn handle_home(&mut self) {} + + fn handle_end(&mut self) {} + + fn handle_delete(&mut self) {} + + fn handle_left_right_action(&mut self) {} + + fn handle_submit(&mut self) {} + + fn handle_esc(&mut self) {} + + fn handle_char_key_event(&mut self) {} + + fn app_mut(&mut self) -> &mut App<'b> { + self.app + } + + fn current_route(&self) -> crate::models::Route { + self.app.get_current_route() + } +} + +pub fn handle_change_tab_left_right_keys(app: &mut App<'_>, key: Key) { + let key_ref = key; + match key_ref { + _ if matches_key!(left, key, app.ignore_special_keys_for_textbox_input) => { + app.data.lidarr_data.main_tabs.previous(); + app.pop_and_push_navigation_stack(app.data.lidarr_data.main_tabs.get_active_route()); + } + _ if matches_key!(right, key, app.ignore_special_keys_for_textbox_input) => { + app.data.lidarr_data.main_tabs.next(); + app.pop_and_push_navigation_stack(app.data.lidarr_data.main_tabs.get_active_route()); + } + _ => (), + } +} diff --git a/src/handlers/mod.rs b/src/handlers/mod.rs index c64ef20..316ba13 100644 --- a/src/handlers/mod.rs +++ b/src/handlers/mod.rs @@ -1,3 +1,4 @@ +use lidarr_handlers::LidarrHandler; use radarr_handlers::RadarrHandler; use sonarr_handlers::SonarrHandler; @@ -15,6 +16,7 @@ use crate::models::stateful_table::StatefulTable; use crate::models::{HorizontallyScrollableText, Route}; mod keybinding_handler; +mod lidarr_handlers; mod radarr_handlers; mod sonarr_handlers; @@ -125,6 +127,9 @@ pub fn handle_events(key: Key, app: &mut App<'_>) { Route::Sonarr(active_sonarr_block, context) => { SonarrHandler::new(key, app, active_sonarr_block, context).handle() } + Route::Lidarr(active_lidarr_block, context) => { + LidarrHandler::new(key, app, active_lidarr_block, context).handle() + } _ => (), } } @@ -187,6 +192,9 @@ fn handle_prompt_toggle(app: &mut App<'_>, key: Key) { Route::Sonarr(_, _) => { app.data.sonarr_data.prompt_confirm = !app.data.sonarr_data.prompt_confirm } + Route::Lidarr(_, _) => { + app.data.lidarr_data.prompt_confirm = !app.data.lidarr_data.prompt_confirm + } _ => (), }, _ => (), diff --git a/src/models/lidarr_models.rs b/src/models/lidarr_models.rs index f17d382..3ed7747 100644 --- a/src/models/lidarr_models.rs +++ b/src/models/lidarr_models.rs @@ -1,7 +1,9 @@ use chrono::{DateTime, Utc}; use derivative::Derivative; +use enum_display_style_derive::EnumDisplayStyle; use serde::{Deserialize, Serialize}; use serde_json::{Number, Value}; +use strum::EnumIter; use super::{HorizontallyScrollableText, Serdeable}; use crate::serde_enum_from; @@ -15,7 +17,6 @@ mod lidarr_models_tests; pub struct Artist { #[serde(deserialize_with = "super::from_i64")] pub id: i64, - pub mb_id: String, pub artist_name: HorizontallyScrollableText, pub foreign_artist_id: String, pub status: ArtistStatus, @@ -35,8 +36,20 @@ pub struct Artist { pub statistics: Option, } -#[derive(Serialize, Deserialize, Default, PartialEq, Eq, Clone, Copy, Debug)] +#[derive( + Serialize, + Deserialize, + Default, + PartialEq, + Eq, + Clone, + Copy, + Debug, + strum::Display, + EnumDisplayStyle, +)] #[serde(rename_all = "camelCase")] +#[strum(serialize_all = "camelCase")] pub enum ArtistStatus { #[default] Continuing, @@ -74,6 +87,86 @@ pub struct ArtistStatistics { impl Eq for ArtistStatistics {} +#[derive(Default, Clone, Serialize, Deserialize, Debug, PartialEq, Eq)] +pub struct MetadataProfile { + #[serde(deserialize_with = "super::from_i64")] + pub id: i64, + pub name: String, +} + +impl From<(&i64, &String)> for MetadataProfile { + fn from(value: (&i64, &String)) -> Self { + MetadataProfile { + id: *value.0, + name: value.1.clone(), + } + } +} + +#[derive(Derivative, Serialize, Deserialize, Debug, Default, Clone, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct DownloadRecord { + pub title: String, + pub status: DownloadStatus, + #[serde(deserialize_with = "super::from_i64")] + pub id: i64, + pub album_id: Option, + pub artist_id: Option, + #[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: Option, +} + +impl Eq for DownloadRecord {} + +#[derive( + Serialize, + Deserialize, + Default, + PartialEq, + Eq, + Clone, + Copy, + Debug, + EnumIter, + strum::Display, + EnumDisplayStyle, +)] +#[serde(rename_all = "camelCase")] +#[strum(serialize_all = "camelCase")] +pub enum DownloadStatus { + #[default] + Unknown, + Queued, + Paused, + Downloading, + Completed, + Failed, + Warning, + Delay, + #[display_style(name = "Download Client Unavailable")] + DownloadClientUnavailable, + Fallback, +} + +#[derive(Default, Clone, Serialize, Deserialize, Debug, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct DownloadsResponse { + pub records: Vec, +} + +#[derive(Default, Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct SystemStatus { + pub version: String, + pub start_time: DateTime, +} + impl From for Serdeable { fn from(value: LidarrSerdeable) -> Serdeable { Serdeable::Lidarr(value) @@ -83,6 +176,13 @@ impl From for Serdeable { serde_enum_from!( LidarrSerdeable { Artists(Vec), + DiskSpaces(Vec), + DownloadsResponse(DownloadsResponse), + MetadataProfiles(Vec), + QualityProfiles(Vec), + RootFolders(Vec), + SystemStatus(SystemStatus), + Tags(Vec), Value(Value), } ); diff --git a/src/models/lidarr_models_tests.rs b/src/models/lidarr_models_tests.rs index 212ad87..70340f3 100644 --- a/src/models/lidarr_models_tests.rs +++ b/src/models/lidarr_models_tests.rs @@ -85,7 +85,6 @@ mod tests { let artist: Artist = serde_json::from_value(artist_json).unwrap(); assert_eq!(artist.id, 1); - assert_str_eq!(artist.mb_id, "test-mb-id"); assert_str_eq!(artist.artist_name.text, "Test Artist"); assert_str_eq!(artist.foreign_artist_id, "test-foreign-id"); assert_eq!(artist.status, ArtistStatus::Continuing); diff --git a/src/models/servarr_data/lidarr/lidarr_data.rs b/src/models/servarr_data/lidarr/lidarr_data.rs index 185e9f1..2a311c3 100644 --- a/src/models/servarr_data/lidarr/lidarr_data.rs +++ b/src/models/servarr_data/lidarr/lidarr_data.rs @@ -1,20 +1,133 @@ +use bimap::BiMap; +use chrono::{DateTime, Utc}; use strum::EnumIter; #[cfg(test)] use strum::{Display, EnumString}; -use crate::models::Route; +use crate::models::{ + Route, TabRoute, TabState, + lidarr_models::{Artist, DownloadRecord}, + servarr_models::{DiskSpace, RootFolder}, + stateful_table::StatefulTable, +}; +use crate::network::lidarr_network::LidarrEvent; #[cfg(test)] #[path = "lidarr_data_tests.rs"] mod lidarr_data_tests; +pub struct LidarrData<'a> { + pub artists: StatefulTable, + pub disk_space_vec: Vec, + pub downloads: StatefulTable, + pub main_tabs: TabState, + pub metadata_profile_map: BiMap, + pub prompt_confirm: bool, + pub prompt_confirm_action: Option, + pub quality_profile_map: BiMap, + pub root_folders: StatefulTable, + pub selected_block: crate::models::BlockSelectionState<'a, ActiveLidarrBlock>, + pub start_time: DateTime, + pub tags_map: BiMap, + pub version: String, +} + +impl LidarrData<'_> { + pub fn reset_sorting(&mut self) { + self.artists.sorting(vec![]); + } +} + +impl<'a> Default for LidarrData<'a> { + fn default() -> LidarrData<'a> { + LidarrData { + artists: StatefulTable::default(), + disk_space_vec: Vec::new(), + downloads: StatefulTable::default(), + metadata_profile_map: BiMap::new(), + prompt_confirm: false, + prompt_confirm_action: None, + quality_profile_map: BiMap::new(), + root_folders: StatefulTable::default(), + selected_block: crate::models::BlockSelectionState::default(), + start_time: Utc::now(), + tags_map: BiMap::new(), + version: String::new(), + main_tabs: TabState::new(vec![ + TabRoute { + title: "Library".to_string(), + route: ActiveLidarrBlock::Artists.into(), + contextual_help: Some(&ARTISTS_CONTEXT_CLUES), + config: None, + }, + ]), + } + } +} + +#[cfg(test)] +impl LidarrData<'_> { + pub fn test_default_fully_populated() -> Self { + use crate::models::lidarr_models::{Artist, DownloadRecord}; + use crate::models::servarr_models::{DiskSpace, RootFolder}; + use crate::models::stateful_table::SortOption; + + let mut lidarr_data = LidarrData::default(); + lidarr_data.artists.set_items(vec![Artist::default()]); + lidarr_data.artists.sorting(vec![SortOption { + name: "Name", + cmp_fn: Some(|a: &Artist, b: &Artist| a.artist_name.text.cmp(&b.artist_name.text)), + }]); + lidarr_data.quality_profile_map = BiMap::from_iter([(1i64, "Lossless".to_owned())]); + lidarr_data.metadata_profile_map = BiMap::from_iter([(1i64, "Standard".to_owned())]); + lidarr_data.tags_map = BiMap::from_iter([(1i64, "usenet".to_owned())]); + lidarr_data.disk_space_vec = vec![DiskSpace { + free_space: 50000000000, + total_space: 100000000000, + }]; + lidarr_data.downloads.set_items(vec![DownloadRecord::default()]); + lidarr_data.root_folders.set_items(vec![RootFolder::default()]); + lidarr_data.version = "1.0.0".to_owned(); + + lidarr_data + } +} + +use crate::app::context_clues::ContextClue; +use crate::app::key_binding::DEFAULT_KEYBINDINGS; + +pub static ARTISTS_CONTEXT_CLUES: [ContextClue; 5] = [ + (DEFAULT_KEYBINDINGS.sort, DEFAULT_KEYBINDINGS.sort.desc), + (DEFAULT_KEYBINDINGS.search, DEFAULT_KEYBINDINGS.search.desc), + (DEFAULT_KEYBINDINGS.filter, DEFAULT_KEYBINDINGS.filter.desc), + ( + DEFAULT_KEYBINDINGS.refresh, + DEFAULT_KEYBINDINGS.refresh.desc, + ), + (DEFAULT_KEYBINDINGS.esc, "cancel filter"), +]; + #[derive(Clone, Copy, PartialEq, Eq, Debug, Default, EnumIter)] #[cfg_attr(test, derive(Display, EnumString))] pub enum ActiveLidarrBlock { #[default] Artists, + ArtistsSortPrompt, + SearchArtists, + SearchArtistsError, + FilterArtists, + FilterArtistsError, } +pub static LIBRARY_BLOCKS: [ActiveLidarrBlock; 6] = [ + ActiveLidarrBlock::Artists, + ActiveLidarrBlock::ArtistsSortPrompt, + ActiveLidarrBlock::SearchArtists, + ActiveLidarrBlock::SearchArtistsError, + ActiveLidarrBlock::FilterArtists, + ActiveLidarrBlock::FilterArtistsError, +]; + impl From for Route { fn from(active_lidarr_block: ActiveLidarrBlock) -> Route { Route::Lidarr(active_lidarr_block, None) diff --git a/src/models/stateful_table.rs b/src/models/stateful_table.rs index a029570..3648819 100644 --- a/src/models/stateful_table.rs +++ b/src/models/stateful_table.rs @@ -174,9 +174,25 @@ where } pub fn set_filtered_items(&mut self, filtered_items: Vec) { + let items_len = filtered_items.len(); self.filtered_items = Some(filtered_items); + + let preserved_selection = self + .filtered_state + .as_ref() + .and_then(|state| state.selected()) + .map_or(0, |i| { + if i > 0 && i < items_len { + i + } else if i >= items_len && items_len > 0 { + items_len - 1 + } else { + 0 + } + }); + let mut filtered_state: TableState = Default::default(); - filtered_state.select(Some(0)); + filtered_state.select(Some(preserved_selection)); self.filtered_state = Some(filtered_state); } diff --git a/src/models/stateful_table_tests.rs b/src/models/stateful_table_tests.rs index 4cb823e..5e0311d 100644 --- a/src/models/stateful_table_tests.rs +++ b/src/models/stateful_table_tests.rs @@ -390,6 +390,47 @@ mod tests { assert_some_eq_x!(&filtered_stateful_table.filtered_items, &filtered_items_vec); } + #[test] + fn test_stateful_table_set_filtered_items_preserves_selection() { + let filtered_items_vec = vec!["Test 1", "Test 2", "Test 3"]; + let mut filtered_stateful_table: StatefulTable<&str> = StatefulTable::default(); + + filtered_stateful_table.set_filtered_items(filtered_items_vec.clone()); + filtered_stateful_table + .filtered_state + .as_mut() + .unwrap() + .select(Some(1)); + + filtered_stateful_table.set_filtered_items(filtered_items_vec.clone()); + + assert_some_eq_x!( + filtered_stateful_table + .filtered_state + .as_ref() + .unwrap() + .selected(), + 1 + ); + + filtered_stateful_table + .filtered_state + .as_mut() + .unwrap() + .select(Some(5)); + + filtered_stateful_table.set_filtered_items(filtered_items_vec); + + assert_some_eq_x!( + filtered_stateful_table + .filtered_state + .as_ref() + .unwrap() + .selected(), + 2 + ); + } + #[test] fn test_stateful_table_current_selection() { let mut stateful_table = create_test_stateful_table(); diff --git a/src/network/lidarr_network/library/lidarr_library_network_tests.rs b/src/network/lidarr_network/library/lidarr_library_network_tests.rs new file mode 100644 index 0000000..5f88749 --- /dev/null +++ b/src/network/lidarr_network/library/lidarr_library_network_tests.rs @@ -0,0 +1,44 @@ +#[cfg(test)] +mod tests { + use crate::models::lidarr_models::{Artist, LidarrSerdeable}; + use crate::network::lidarr_network::LidarrEvent; + use crate::network::network_tests::test_utils::{MockServarrApi, test_network}; + use pretty_assertions::assert_eq; + use serde_json::json; + + #[tokio::test] + async fn test_handle_list_artists_event() { + let artists_json = json!([{ + "id": 1, + "mbId": "test-mb-id", + "artistName": "Test Artist", + "foreignArtistId": "test-foreign-id", + "status": "continuing", + "path": "/music/test-artist", + "qualityProfileId": 1, + "metadataProfileId": 1, + "monitored": true, + "genres": [], + "tags": [], + "added": "2023-01-01T00:00:00Z" + }]); + let response: Vec = serde_json::from_value(artists_json.clone()).unwrap(); + let (mock, app, _server) = MockServarrApi::get() + .returns(artists_json) + .build_for(LidarrEvent::ListArtists) + .await; + app.lock().await.server_tabs.set_index(2); + let mut network = test_network(&app); + + let result = network.handle_lidarr_event(LidarrEvent::ListArtists).await; + + mock.assert_async().await; + + let LidarrSerdeable::Artists(artists) = result.unwrap() else { + panic!("Expected Artists"); + }; + + assert_eq!(artists, response); + assert!(!app.lock().await.data.lidarr_data.artists.is_empty()); + } +} diff --git a/src/network/lidarr_network/library/mod.rs b/src/network/lidarr_network/library/mod.rs new file mode 100644 index 0000000..77b8a80 --- /dev/null +++ b/src/network/lidarr_network/library/mod.rs @@ -0,0 +1,36 @@ +use anyhow::Result; +use log::info; + +use crate::models::lidarr_models::Artist; +use crate::models::servarr_data::lidarr::lidarr_data::ActiveLidarrBlock; +use crate::models::Route; +use crate::network::lidarr_network::LidarrEvent; +use crate::network::{Network, RequestMethod}; + +#[cfg(test)] +#[path = "lidarr_library_network_tests.rs"] +mod lidarr_library_network_tests; + +impl Network<'_, '_> { + pub(in crate::network::lidarr_network) async fn list_artists(&mut self) -> Result> { + info!("Fetching Lidarr artists"); + let event = LidarrEvent::ListArtists; + + let request_props = self + .request_props_from(event, RequestMethod::Get, None::<()>, None, None) + .await; + + self + .handle_request::<(), Vec>(request_props, |mut artists_vec, mut app| { + if !matches!( + app.get_current_route(), + Route::Lidarr(ActiveLidarrBlock::ArtistsSortPrompt, _) + ) { + artists_vec.sort_by(|a, b| a.id.cmp(&b.id)); + app.data.lidarr_data.artists.set_items(artists_vec); + app.data.lidarr_data.artists.apply_sorting_toggle(false); + } + }) + .await + } +} diff --git a/src/network/lidarr_network/lidarr_network_tests.rs b/src/network/lidarr_network/lidarr_network_tests.rs index c56fac5..53371ac 100644 --- a/src/network/lidarr_network/lidarr_network_tests.rs +++ b/src/network/lidarr_network/lidarr_network_tests.rs @@ -1,13 +1,17 @@ #[cfg(test)] mod tests { - use crate::models::lidarr_models::{Artist, LidarrSerdeable}; - use crate::network::network_tests::test_utils::{MockServarrApi, test_network}; use crate::network::{NetworkEvent, NetworkResource, lidarr_network::LidarrEvent}; - use pretty_assertions::{assert_eq, assert_str_eq}; + use pretty_assertions::assert_str_eq; use rstest::rstest; - use serde_json::json; #[rstest] + #[case(LidarrEvent::GetDiskSpace, "/diskspace")] + #[case(LidarrEvent::GetDownloads(500), "/queue")] + #[case(LidarrEvent::GetMetadataProfiles, "/metadataprofile")] + #[case(LidarrEvent::GetQualityProfiles, "/qualityprofile")] + #[case(LidarrEvent::GetRootFolders, "/rootfolder")] + #[case(LidarrEvent::GetStatus, "/system/status")] + #[case(LidarrEvent::GetTags, "/tag")] #[case(LidarrEvent::HealthCheck, "/health")] #[case(LidarrEvent::ListArtists, "/artist")] fn test_resource(#[case] event: LidarrEvent, #[case] expected_uri: &str) { @@ -21,52 +25,4 @@ mod tests { NetworkEvent::from(LidarrEvent::HealthCheck) ); } - - #[tokio::test] - async fn test_handle_get_lidarr_healthcheck_event() { - let (mock, app, _server) = MockServarrApi::get() - .build_for(LidarrEvent::HealthCheck) - .await; - app.lock().await.server_tabs.set_index(2); - let mut network = test_network(&app); - - let _ = network.handle_lidarr_event(LidarrEvent::HealthCheck).await; - - mock.assert_async().await; - } - - #[tokio::test] - async fn test_handle_list_artists_event() { - let artists_json = json!([{ - "id": 1, - "mbId": "test-mb-id", - "artistName": "Test Artist", - "foreignArtistId": "test-foreign-id", - "status": "continuing", - "path": "/music/test-artist", - "qualityProfileId": 1, - "metadataProfileId": 1, - "monitored": true, - "genres": [], - "tags": [], - "added": "2023-01-01T00:00:00Z" - }]); - let response: Vec = serde_json::from_value(artists_json.clone()).unwrap(); - let (mock, app, _server) = MockServarrApi::get() - .returns(artists_json) - .build_for(LidarrEvent::ListArtists) - .await; - app.lock().await.server_tabs.set_index(2); - let mut network = test_network(&app); - - let result = network.handle_lidarr_event(LidarrEvent::ListArtists).await; - - mock.assert_async().await; - - let LidarrSerdeable::Artists(artists) = result.unwrap() else { - panic!("Expected Artists"); - }; - - assert_eq!(artists, response); - } } diff --git a/src/network/lidarr_network/mod.rs b/src/network/lidarr_network/mod.rs index 74c32a1..86981d7 100644 --- a/src/network/lidarr_network/mod.rs +++ b/src/network/lidarr_network/mod.rs @@ -1,11 +1,11 @@ use anyhow::Result; -use log::info; -use super::{Network, NetworkEvent, NetworkResource}; -use crate::{ - models::lidarr_models::{Artist, LidarrSerdeable}, - network::RequestMethod, -}; +use super::{NetworkEvent, NetworkResource}; +use crate::models::lidarr_models::LidarrSerdeable; +use crate::network::Network; + +mod library; +mod system; #[cfg(test)] #[path = "lidarr_network_tests.rs"] @@ -13,6 +13,13 @@ mod lidarr_network_tests; #[derive(Debug, Eq, PartialEq, Clone)] pub enum LidarrEvent { + GetDiskSpace, + GetDownloads(u64), + GetMetadataProfiles, + GetQualityProfiles, + GetRootFolders, + GetStatus, + GetTags, HealthCheck, ListArtists, } @@ -20,6 +27,13 @@ pub enum LidarrEvent { impl NetworkResource for LidarrEvent { fn resource(&self) -> &'static str { match &self { + LidarrEvent::GetDiskSpace => "/diskspace", + LidarrEvent::GetDownloads(_) => "/queue", + LidarrEvent::GetMetadataProfiles => "/metadataprofile", + LidarrEvent::GetQualityProfiles => "/qualityprofile", + LidarrEvent::GetRootFolders => "/rootfolder", + LidarrEvent::GetStatus => "/system/status", + LidarrEvent::GetTags => "/tag", LidarrEvent::HealthCheck => "/health", LidarrEvent::ListArtists => "/artist", } @@ -38,6 +52,31 @@ impl Network<'_, '_> { lidarr_event: LidarrEvent, ) -> Result { match lidarr_event { + LidarrEvent::GetDiskSpace => self + .get_lidarr_diskspace() + .await + .map(LidarrSerdeable::from), + LidarrEvent::GetDownloads(count) => self + .get_lidarr_downloads(count) + .await + .map(LidarrSerdeable::from), + LidarrEvent::GetMetadataProfiles => self + .get_lidarr_metadata_profiles() + .await + .map(LidarrSerdeable::from), + LidarrEvent::GetQualityProfiles => self + .get_lidarr_quality_profiles() + .await + .map(LidarrSerdeable::from), + LidarrEvent::GetRootFolders => self + .get_lidarr_root_folders() + .await + .map(LidarrSerdeable::from), + LidarrEvent::GetStatus => self + .get_lidarr_status() + .await + .map(LidarrSerdeable::from), + LidarrEvent::GetTags => self.get_lidarr_tags().await.map(LidarrSerdeable::from), LidarrEvent::HealthCheck => self .get_lidarr_healthcheck() .await @@ -45,30 +84,4 @@ impl Network<'_, '_> { LidarrEvent::ListArtists => self.list_artists().await.map(LidarrSerdeable::from), } } - - async fn get_lidarr_healthcheck(&mut self) -> Result<()> { - info!("Performing Lidarr health check"); - let event = LidarrEvent::HealthCheck; - - let request_props = self - .request_props_from(event, RequestMethod::Get, None::<()>, None, None) - .await; - - self - .handle_request::<(), ()>(request_props, |_, _| ()) - .await - } - - async fn list_artists(&mut self) -> Result> { - info!("Fetching Lidarr artists"); - let event = LidarrEvent::ListArtists; - - let request_props = self - .request_props_from(event, RequestMethod::Get, None::<()>, None, None) - .await; - - self - .handle_request::<(), Vec>(request_props, |_, _| ()) - .await - } } diff --git a/src/network/lidarr_network/system/lidarr_system_network_tests.rs b/src/network/lidarr_network/system/lidarr_system_network_tests.rs new file mode 100644 index 0000000..4695fc9 --- /dev/null +++ b/src/network/lidarr_network/system/lidarr_system_network_tests.rs @@ -0,0 +1,246 @@ +#[cfg(test)] +mod tests { + use crate::models::lidarr_models::{ + DownloadsResponse, LidarrSerdeable, MetadataProfile, SystemStatus, + }; + use crate::models::servarr_models::{DiskSpace, QualityProfile, RootFolder, Tag}; + use crate::network::lidarr_network::LidarrEvent; + use crate::network::network_tests::test_utils::{MockServarrApi, test_network}; + use pretty_assertions::assert_eq; + use serde_json::json; + + #[tokio::test] + async fn test_handle_get_lidarr_healthcheck_event() { + let (mock, app, _server) = MockServarrApi::get() + .build_for(LidarrEvent::HealthCheck) + .await; + app.lock().await.server_tabs.set_index(2); + let mut network = test_network(&app); + + let _ = network.handle_lidarr_event(LidarrEvent::HealthCheck).await; + + mock.assert_async().await; + } + + #[tokio::test] + async fn test_handle_get_metadata_profiles_event() { + let metadata_profiles_json = json!([{ + "id": 1, + "name": "Standard" + }]); + let response: Vec = + serde_json::from_value(metadata_profiles_json.clone()).unwrap(); + let (mock, app, _server) = MockServarrApi::get() + .returns(metadata_profiles_json) + .build_for(LidarrEvent::GetMetadataProfiles) + .await; + app.lock().await.server_tabs.set_index(2); + let mut network = test_network(&app); + + let result = network + .handle_lidarr_event(LidarrEvent::GetMetadataProfiles) + .await; + + mock.assert_async().await; + + let LidarrSerdeable::MetadataProfiles(metadata_profiles) = result.unwrap() else { + panic!("Expected MetadataProfiles"); + }; + + assert_eq!(metadata_profiles, response); + assert_eq!( + app + .lock() + .await + .data + .lidarr_data + .metadata_profile_map + .get_by_left(&1), + Some(&"Standard".to_owned()) + ); + } + + #[tokio::test] + async fn test_handle_get_quality_profiles_event() { + let quality_profiles_json = json!([{ + "id": 1, + "name": "Lossless" + }]); + let response: Vec = + serde_json::from_value(quality_profiles_json.clone()).unwrap(); + let (mock, app, _server) = MockServarrApi::get() + .returns(quality_profiles_json) + .build_for(LidarrEvent::GetQualityProfiles) + .await; + app.lock().await.server_tabs.set_index(2); + let mut network = test_network(&app); + + let result = network + .handle_lidarr_event(LidarrEvent::GetQualityProfiles) + .await; + + mock.assert_async().await; + + let LidarrSerdeable::QualityProfiles(quality_profiles) = result.unwrap() else { + panic!("Expected QualityProfiles"); + }; + + assert_eq!(quality_profiles, response); + assert_eq!( + app + .lock() + .await + .data + .lidarr_data + .quality_profile_map + .get_by_left(&1), + Some(&"Lossless".to_owned()) + ); + } + + #[tokio::test] + async fn test_handle_get_tags_event() { + let tags_json = json!([{ + "id": 1, + "label": "usenet" + }]); + let response: Vec = serde_json::from_value(tags_json.clone()).unwrap(); + let (mock, app, _server) = MockServarrApi::get() + .returns(tags_json) + .build_for(LidarrEvent::GetTags) + .await; + app.lock().await.server_tabs.set_index(2); + let mut network = test_network(&app); + + let result = network.handle_lidarr_event(LidarrEvent::GetTags).await; + + mock.assert_async().await; + + let LidarrSerdeable::Tags(tags) = result.unwrap() else { + panic!("Expected Tags"); + }; + + assert_eq!(tags, response); + assert_eq!( + app.lock().await.data.lidarr_data.tags_map.get_by_left(&1), + Some(&"usenet".to_owned()) + ); + } + + #[tokio::test] + async fn test_handle_get_diskspace_event() { + let diskspace_json = json!([{ + "freeSpace": 50000000000i64, + "totalSpace": 100000000000i64 + }]); + let response: Vec = serde_json::from_value(diskspace_json.clone()).unwrap(); + let (mock, app, _server) = MockServarrApi::get() + .returns(diskspace_json) + .build_for(LidarrEvent::GetDiskSpace) + .await; + app.lock().await.server_tabs.set_index(2); + let mut network = test_network(&app); + + let result = network.handle_lidarr_event(LidarrEvent::GetDiskSpace).await; + + mock.assert_async().await; + + let LidarrSerdeable::DiskSpaces(disk_spaces) = result.unwrap() else { + panic!("Expected DiskSpaces"); + }; + + assert_eq!(disk_spaces, response); + assert!(!app.lock().await.data.lidarr_data.disk_space_vec.is_empty()); + } + + #[tokio::test] + async fn test_handle_get_downloads_event() { + let downloads_json = json!({ + "records": [{ + "title": "Test Album", + "status": "downloading", + "id": 1, + "size": 100.0, + "sizeleft": 50.0, + "indexer": "test-indexer" + }] + }); + let response: DownloadsResponse = serde_json::from_value(downloads_json.clone()).unwrap(); + let (mock, app, _server) = MockServarrApi::get() + .returns(downloads_json) + .query("pageSize=500") + .build_for(LidarrEvent::GetDownloads(500)) + .await; + app.lock().await.server_tabs.set_index(2); + let mut network = test_network(&app); + + let result = network + .handle_lidarr_event(LidarrEvent::GetDownloads(500)) + .await; + + mock.assert_async().await; + + let LidarrSerdeable::DownloadsResponse(downloads_response) = result.unwrap() else { + panic!("Expected DownloadsResponse"); + }; + + assert_eq!(downloads_response, response); + assert!(!app.lock().await.data.lidarr_data.downloads.is_empty()); + } + + #[tokio::test] + async fn test_handle_get_root_folders_event() { + let root_folders_json = json!([{ + "id": 1, + "path": "/music", + "accessible": true, + "freeSpace": 50000000000i64 + }]); + let response: Vec = serde_json::from_value(root_folders_json.clone()).unwrap(); + let (mock, app, _server) = MockServarrApi::get() + .returns(root_folders_json) + .build_for(LidarrEvent::GetRootFolders) + .await; + app.lock().await.server_tabs.set_index(2); + let mut network = test_network(&app); + + let result = network + .handle_lidarr_event(LidarrEvent::GetRootFolders) + .await; + + mock.assert_async().await; + + let LidarrSerdeable::RootFolders(root_folders) = result.unwrap() else { + panic!("Expected RootFolders"); + }; + + assert_eq!(root_folders, response); + assert!(!app.lock().await.data.lidarr_data.root_folders.is_empty()); + } + + #[tokio::test] + async fn test_handle_get_status_event() { + let status_json = json!({ + "version": "1.0.0", + "startTime": "2023-01-01T00:00:00Z" + }); + let response: SystemStatus = serde_json::from_value(status_json.clone()).unwrap(); + let (mock, app, _server) = MockServarrApi::get() + .returns(status_json) + .build_for(LidarrEvent::GetStatus) + .await; + app.lock().await.server_tabs.set_index(2); + let mut network = test_network(&app); + + let result = network.handle_lidarr_event(LidarrEvent::GetStatus).await; + + mock.assert_async().await; + + let LidarrSerdeable::SystemStatus(status) = result.unwrap() else { + panic!("Expected SystemStatus"); + }; + + assert_eq!(status, response); + assert_eq!(app.lock().await.data.lidarr_data.version, "1.0.0"); + } +} diff --git a/src/network/lidarr_network/system/mod.rs b/src/network/lidarr_network/system/mod.rs new file mode 100644 index 0000000..8cbedf2 --- /dev/null +++ b/src/network/lidarr_network/system/mod.rs @@ -0,0 +1,164 @@ +use anyhow::Result; +use log::info; + +use crate::models::lidarr_models::{DownloadsResponse, MetadataProfile, SystemStatus}; +use crate::models::servarr_models::{DiskSpace, QualityProfile, RootFolder, Tag}; +use crate::network::lidarr_network::LidarrEvent; +use crate::network::{Network, RequestMethod}; + +#[cfg(test)] +#[path = "lidarr_system_network_tests.rs"] +mod lidarr_system_network_tests; + +impl Network<'_, '_> { + pub(in crate::network::lidarr_network) async fn get_lidarr_healthcheck(&mut self) -> Result<()> { + info!("Performing Lidarr health check"); + let event = LidarrEvent::HealthCheck; + + let request_props = self + .request_props_from(event, RequestMethod::Get, None::<()>, None, None) + .await; + + self + .handle_request::<(), ()>(request_props, |_, _| ()) + .await + } + + pub(in crate::network::lidarr_network) async fn get_lidarr_metadata_profiles( + &mut self, + ) -> Result> { + info!("Fetching Lidarr metadata profiles"); + let event = LidarrEvent::GetMetadataProfiles; + + let request_props = self + .request_props_from(event, RequestMethod::Get, None::<()>, None, None) + .await; + + self + .handle_request::<(), Vec>(request_props, |metadata_profiles, mut app| { + app.data.lidarr_data.metadata_profile_map = metadata_profiles + .into_iter() + .map(|profile| (profile.id, profile.name)) + .collect(); + }) + .await + } + + pub(in crate::network::lidarr_network) async fn get_lidarr_quality_profiles( + &mut self, + ) -> Result> { + info!("Fetching Lidarr quality profiles"); + let event = LidarrEvent::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.lidarr_data.quality_profile_map = quality_profiles + .into_iter() + .map(|profile| (profile.id, profile.name)) + .collect(); + }) + .await + } + + pub(in crate::network::lidarr_network) async fn get_lidarr_tags(&mut self) -> Result> { + info!("Fetching Lidarr tags"); + let event = LidarrEvent::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.lidarr_data.tags_map = tags_vec + .into_iter() + .map(|tag| (tag.id, tag.label)) + .collect(); + }) + .await + } + + pub(in crate::network::lidarr_network) async fn get_lidarr_diskspace( + &mut self, + ) -> Result> { + info!("Fetching Lidarr disk space"); + let event = LidarrEvent::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.lidarr_data.disk_space_vec = disk_space_vec; + }) + .await + } + + pub(in crate::network::lidarr_network) async fn get_lidarr_downloads( + &mut self, + count: u64, + ) -> Result { + info!("Fetching Lidarr downloads"); + let event = LidarrEvent::GetDownloads(count); + + let request_props = self + .request_props_from( + event, + RequestMethod::Get, + None::<()>, + None, + Some(format!("pageSize={count}")), + ) + .await; + + self + .handle_request::<(), DownloadsResponse>(request_props, |queue_response, mut app| { + app + .data + .lidarr_data + .downloads + .set_items(queue_response.records); + }) + .await + } + + pub(in crate::network::lidarr_network) async fn get_lidarr_root_folders( + &mut self, + ) -> Result> { + info!("Fetching Lidarr root folders"); + let event = LidarrEvent::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.lidarr_data.root_folders.set_items(root_folders); + }) + .await + } + + pub(in crate::network::lidarr_network) async fn get_lidarr_status( + &mut self, + ) -> Result { + info!("Fetching Lidarr system status"); + let event = LidarrEvent::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.lidarr_data.version = system_status.version; + app.data.lidarr_data.start_time = system_status.start_time; + }) + .await + } +} diff --git a/src/network/sonarr_network/library/series/mod.rs b/src/network/sonarr_network/library/series/mod.rs index 100a162..3c532c8 100644 --- a/src/network/sonarr_network/library/series/mod.rs +++ b/src/network/sonarr_network/library/series/mod.rs @@ -20,7 +20,7 @@ impl Network<'_, '_> { pub(in crate::network::sonarr_network) async fn add_sonarr_series( &mut self, mut add_series_body: AddSeriesBody, - ) -> anyhow::Result { + ) -> Result { info!("Adding new series to Sonarr"); let event = SonarrEvent::AddSeries(AddSeriesBody::default()); if let Some(tag_input_str) = add_series_body.tag_input_string.as_ref() { diff --git a/src/ui/lidarr_ui/library/library_ui_tests.rs b/src/ui/lidarr_ui/library/library_ui_tests.rs new file mode 100644 index 0000000..92bf3ce --- /dev/null +++ b/src/ui/lidarr_ui/library/library_ui_tests.rs @@ -0,0 +1,20 @@ +#[cfg(test)] +mod tests { + use strum::IntoEnumIterator; + + use crate::models::servarr_data::lidarr::lidarr_data::{ActiveLidarrBlock, LIBRARY_BLOCKS}; + use crate::models::Route; + use crate::ui::DrawUi; + use crate::ui::lidarr_ui::library::LibraryUi; + + #[test] + fn test_library_ui_accepts() { + for lidarr_block in ActiveLidarrBlock::iter() { + if LIBRARY_BLOCKS.contains(&lidarr_block) { + assert!(LibraryUi::accepts(Route::Lidarr(lidarr_block, None))); + } else { + assert!(!LibraryUi::accepts(Route::Lidarr(lidarr_block, None))); + } + } + } +} diff --git a/src/ui/lidarr_ui/library/mod.rs b/src/ui/lidarr_ui/library/mod.rs new file mode 100644 index 0000000..a54c803 --- /dev/null +++ b/src/ui/lidarr_ui/library/mod.rs @@ -0,0 +1,185 @@ +use ratatui::{ + Frame, + layout::{Constraint, Rect}, + widgets::{Cell, Row}, +}; + +use crate::ui::widgets::managarr_table::ManagarrTable; +use crate::utils::convert_to_gb; +use crate::{ + app::App, + models::{ + Route, + lidarr_models::{Artist, ArtistStatus}, + servarr_data::lidarr::lidarr_data::{ActiveLidarrBlock, LIBRARY_BLOCKS}, + }, + ui::{ + DrawUi, + styles::ManagarrStyle, + utils::{get_width_from_percentage, layout_block_top_border}, + }, +}; + +#[cfg(test)] +#[path = "library_ui_tests.rs"] +mod library_ui_tests; + +pub(super) struct LibraryUi; + +impl DrawUi for LibraryUi { + fn accepts(route: Route) -> bool { + if let Route::Lidarr(active_lidarr_block, _) = route { + return LIBRARY_BLOCKS.contains(&active_lidarr_block); + } + + false + } + + fn draw(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { + draw_library(f, app, area); + } +} + +fn draw_library(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { + if let Route::Lidarr(active_lidarr_block, _) = app.get_current_route() { + let current_selection = if !app.data.lidarr_data.artists.items.is_empty() { + app.data.lidarr_data.artists.current_selection().clone() + } else { + Artist::default() + }; + let quality_profile_map = &app.data.lidarr_data.quality_profile_map; + let metadata_profile_map = &app.data.lidarr_data.metadata_profile_map; + let tags_map = &app.data.lidarr_data.tags_map; + let content = Some(&mut app.data.lidarr_data.artists); + + let artists_table_row_mapping = |artist: &Artist| { + artist.artist_name.scroll_left_or_reset( + get_width_from_percentage(area, 25), + *artist == current_selection, + app.ui_scroll_tick_count == 0, + ); + let monitored = if artist.monitored { "🏷" } else { "" }; + let artist_type = artist.artist_type.clone().unwrap_or_default(); + let size = artist + .statistics + .as_ref() + .map_or(0f64, |stats| convert_to_gb(stats.size_on_disk)); + let quality_profile = quality_profile_map + .get_by_left(&artist.quality_profile_id) + .cloned() + .unwrap_or_default(); + let metadata_profile = metadata_profile_map + .get_by_left(&artist.metadata_profile_id) + .cloned() + .unwrap_or_default(); + let albums = artist + .statistics + .as_ref() + .map_or(0, |stats| stats.album_count); + let tracks = artist + .statistics + .as_ref() + .map_or(String::new(), |stats| { + format!("{}/{}", stats.track_file_count, stats.total_track_count) + }); + let tags = artist + .tags + .iter() + .filter_map(|tag_id| { + let id = tag_id.as_i64()?; + tags_map.get_by_left(&id).cloned() + }) + .collect::>() + .join(", "); + + decorate_artist_row_with_style( + artist, + Row::new(vec![ + Cell::from(artist.artist_name.to_string()), + Cell::from(artist_type), + Cell::from(artist.status.to_display_str()), + Cell::from(quality_profile), + Cell::from(metadata_profile), + Cell::from(albums.to_string()), + Cell::from(tracks), + Cell::from(format!("{size:.2} GB")), + Cell::from(monitored.to_owned()), + Cell::from(tags), + ]), + ) + }; + let artists_table = ManagarrTable::new(content, artists_table_row_mapping) + .block(layout_block_top_border()) + .loading(app.is_loading) + .sorting(active_lidarr_block == ActiveLidarrBlock::ArtistsSortPrompt) + .searching(active_lidarr_block == ActiveLidarrBlock::SearchArtists) + .filtering(active_lidarr_block == ActiveLidarrBlock::FilterArtists) + .search_produced_empty_results(active_lidarr_block == ActiveLidarrBlock::SearchArtistsError) + .filter_produced_empty_results(active_lidarr_block == ActiveLidarrBlock::FilterArtistsError) + .headers([ + "Name", + "Type", + "Status", + "Quality Profile", + "Metadata Profile", + "Albums", + "Tracks", + "Size", + "Monitored", + "Tags", + ]) + .constraints([ + Constraint::Percentage(22), + Constraint::Percentage(8), + Constraint::Percentage(8), + Constraint::Percentage(12), + Constraint::Percentage(12), + Constraint::Percentage(6), + Constraint::Percentage(8), + Constraint::Percentage(7), + Constraint::Percentage(6), + Constraint::Percentage(11), + ]); + + if [ + ActiveLidarrBlock::SearchArtists, + ActiveLidarrBlock::FilterArtists, + ] + .contains(&active_lidarr_block) + { + artists_table.show_cursor(f, area); + } + + f.render_widget(artists_table, area); + } +} + +fn decorate_artist_row_with_style<'a>(artist: &Artist, row: Row<'a>) -> Row<'a> { + if !artist.monitored { + return row.unmonitored(); + } + + match artist.status { + ArtistStatus::Ended => { + if let Some(ref stats) = artist.statistics { + return if stats.track_file_count == stats.total_track_count && stats.total_track_count > 0 { + row.downloaded() + } else { + row.missing() + }; + } + row.indeterminate() + } + ArtistStatus::Continuing => { + if let Some(ref stats) = artist.statistics { + return if stats.track_file_count == stats.total_track_count && stats.total_track_count > 0 { + row.unreleased() + } else { + row.missing() + }; + } + row.indeterminate() + } + _ => row.indeterminate(), + } +} diff --git a/src/ui/lidarr_ui/lidarr_ui_tests.rs b/src/ui/lidarr_ui/lidarr_ui_tests.rs new file mode 100644 index 0000000..d29e8d1 --- /dev/null +++ b/src/ui/lidarr_ui/lidarr_ui_tests.rs @@ -0,0 +1,16 @@ +#[cfg(test)] +mod tests { + use strum::IntoEnumIterator; + + use crate::models::servarr_data::lidarr::lidarr_data::ActiveLidarrBlock; + use crate::models::Route; + use crate::ui::DrawUi; + use crate::ui::lidarr_ui::LidarrUi; + + #[test] + fn test_lidarr_ui_accepts() { + for lidarr_block in ActiveLidarrBlock::iter() { + assert!(LidarrUi::accepts(Route::Lidarr(lidarr_block, None))); + } + } +} diff --git a/src/ui/lidarr_ui/mod.rs b/src/ui/lidarr_ui/mod.rs new file mode 100644 index 0000000..faec50f --- /dev/null +++ b/src/ui/lidarr_ui/mod.rs @@ -0,0 +1,209 @@ +use std::{cmp, iter}; + +#[cfg(test)] +use crate::ui::ui_test_utils::test_utils::Utc; +use chrono::Duration; +#[cfg(not(test))] +use chrono::Utc; +use library::LibraryUi; +use ratatui::{ + Frame, + layout::{Constraint, Layout, Rect}, + style::Stylize, + text::Text, + widgets::Paragraph, +}; + +use crate::{ + app::App, + logos::LIDARR_LOGO, + models::{ + Route, + lidarr_models::DownloadRecord, + servarr_data::lidarr::lidarr_data::LidarrData, + servarr_models::{DiskSpace, RootFolder}, + }, + utils::convert_to_gb, +}; + +use super::{ + DrawUi, draw_tabs, + styles::ManagarrStyle, + utils::{borderless_block, layout_block, line_gauge_with_label, line_gauge_with_title, title_block}, + widgets::loading_block::LoadingBlock, +}; + +mod library; + +#[cfg(test)] +#[path = "lidarr_ui_tests.rs"] +mod lidarr_ui_tests; + +pub(super) struct LidarrUi; + +impl DrawUi for LidarrUi { + fn accepts(route: Route) -> bool { + matches!(route, Route::Lidarr(_, _)) + } + + fn draw(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { + let content_area = draw_tabs(f, area, "Artists", &app.data.lidarr_data.main_tabs); + let route = app.get_current_route(); + + match route { + _ if LibraryUi::accepts(route) => LibraryUi::draw(f, app, content_area), + _ => (), + } + } + + fn draw_context_row(f: &mut Frame<'_>, app: &App<'_>, area: Rect) { + let [main_area, logo_area] = + Layout::horizontal([Constraint::Fill(0), Constraint::Length(20)]).areas(area); + + let [stats_area, downloads_area] = + Layout::horizontal([Constraint::Ratio(1, 2), Constraint::Ratio(1, 2)]).areas(main_area); + + draw_stats_context(f, app, stats_area); + draw_downloads_context(f, app, downloads_area); + draw_lidarr_logo(f, logo_area); + } +} + +fn draw_stats_context(f: &mut Frame<'_>, app: &App<'_>, area: Rect) { + let block = title_block("Stats"); + + if !app.data.lidarr_data.version.is_empty() { + f.render_widget(block, area); + let LidarrData { + root_folders, + disk_space_vec, + start_time, + .. + } = &app.data.lidarr_data; + + let mut constraints = vec![ + Constraint::Length(1), + Constraint::Length(1), + Constraint::Length(1), + ]; + + constraints.append( + &mut iter::repeat_n( + Constraint::Length(1), + disk_space_vec.len() + root_folders.items.len() + 1, + ) + .collect(), + ); + + let stat_item_areas = Layout::vertical(constraints).margin(1).split(area); + + let version_paragraph = Paragraph::new(Text::from(format!( + "Lidarr Version: {}", + app.data.lidarr_data.version + ))) + .block(borderless_block()) + .bold(); + + let uptime = Utc::now() - start_time.to_owned(); + let days = uptime.num_days(); + let day_difference = uptime - Duration::days(days); + let hours = day_difference.num_hours(); + let hour_difference = day_difference - Duration::hours(hours); + let minutes = hour_difference.num_minutes(); + let seconds = (hour_difference - Duration::minutes(minutes)).num_seconds(); + + let uptime_paragraph = Paragraph::new(Text::from(format!( + "Uptime: {days}d {hours:0width$}:{minutes:0width$}:{seconds:0width$}", + width = 2 + ))) + .block(borderless_block()) + .bold(); + + let storage = Paragraph::new(Text::from("Storage:")).block(borderless_block().bold()); + let folders = Paragraph::new(Text::from("Root Folders:")).block(borderless_block().bold()); + + f.render_widget(version_paragraph, stat_item_areas[0]); + f.render_widget(uptime_paragraph, stat_item_areas[1]); + f.render_widget(storage, stat_item_areas[2]); + + for i in 0..disk_space_vec.len() { + let DiskSpace { + free_space, + total_space, + } = &disk_space_vec[i]; + let title = format!("Disk {}", i + 1); + let ratio = if *total_space == 0 { + 0f64 + } else { + 1f64 - (*free_space as f64 / *total_space as f64) + }; + + let space_gauge = line_gauge_with_label(title.as_str(), ratio); + + f.render_widget(space_gauge, stat_item_areas[i + 3]); + } + + f.render_widget(folders, stat_item_areas[disk_space_vec.len() + 3]); + + for i in 0..root_folders.items.len() { + let RootFolder { + path, free_space, .. + } = &root_folders.items[i]; + let space: f64 = convert_to_gb(*free_space); + let root_folder_space = Paragraph::new(format!("{path}: {space:.2} GB free")) + .block(borderless_block()) + .default(); + + f.render_widget( + root_folder_space, + stat_item_areas[i + disk_space_vec.len() + 4], + ) + } + } else { + f.render_widget(LoadingBlock::new(app.is_loading, block), area); + } +} + +fn draw_downloads_context(f: &mut Frame<'_>, app: &App<'_>, area: Rect) { + let block = title_block("Downloads"); + let downloads_vec = &app.data.lidarr_data.downloads.items; + + if !downloads_vec.is_empty() { + f.render_widget(block, area); + + let max_items = ((((area.height as f64 / 2.0).floor() * 2.0) as i64) / 2) - 1; + let items = cmp::min(downloads_vec.len(), max_items.unsigned_abs() as usize); + let download_item_areas = + Layout::vertical(iter::repeat_n(Constraint::Length(2), items).collect::>()) + .margin(1) + .split(area); + + for i in 0..items { + let DownloadRecord { + title, + sizeleft, + size, + .. + } = &downloads_vec[i]; + let percent = if *size == 0.0 { + 0.0 + } else { + 1f64 - (*sizeleft / *size) + }; + let download_gauge = line_gauge_with_title(title, percent); + + f.render_widget(download_gauge, download_item_areas[i]); + } + } else { + f.render_widget(LoadingBlock::new(app.is_loading, block), area); + } +} + +fn draw_lidarr_logo(f: &mut Frame<'_>, area: Rect) { + let logo_text = Text::from(LIDARR_LOGO); + let logo = Paragraph::new(logo_text) + .light_green() + .block(layout_block().default()) + .centered(); + f.render_widget(logo, area); +} diff --git a/src/ui/mod.rs b/src/ui/mod.rs index d974eee..71e6837 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -9,6 +9,7 @@ use ratatui::widgets::Paragraph; use ratatui::widgets::Tabs; use ratatui::widgets::Wrap; use ratatui::widgets::{Clear, Row}; +use lidarr_ui::LidarrUi; use sonarr_ui::SonarrUi; use utils::layout_block; @@ -27,6 +28,7 @@ use crate::ui::widgets::managarr_table::ManagarrTable; use crate::ui::widgets::popup::Size; mod builtin_themes; +mod lidarr_ui; mod radarr_ui; mod sonarr_ui; mod styles; @@ -86,6 +88,10 @@ pub fn ui(f: &mut Frame<'_>, app: &mut App<'_>) { SonarrUi::draw_context_row(f, app, context_area); SonarrUi::draw(f, app, table_area); } + route if LidarrUi::accepts(route) => { + LidarrUi::draw_context_row(f, app, context_area); + LidarrUi::draw(f, app, table_area); + } _ => (), } -- 2.52.0 From 6771a0ab3812b98de2795e0d6080a678d63e592d Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Mon, 5 Jan 2026 15:44:51 -0700 Subject: [PATCH 04/61] feat: Full support for deleting an artist via CLI and TUI --- src/app/lidarr/lidarr_context_clues.rs | 13 + src/app/lidarr/lidarr_context_clues_tests.rs | 8 +- src/app/lidarr/lidarr_tests.rs | 4 + src/app/lidarr/mod.rs | 2 +- src/app/mod.rs | 2 +- src/app/sonarr/sonarr_context_clues_tests.rs | 1 - src/cli/cli_tests.rs | 17 +- src/cli/lidarr/delete_command_handler.rs | 80 ++++ .../lidarr/delete_command_handler_tests.rs | 145 +++++++ src/cli/lidarr/lidarr_command_tests.rs | 87 ++++ src/cli/lidarr/mod.rs | 17 +- .../library/delete_artist_handler.rs | 149 +++++++ .../library/delete_artist_handler_tests.rs | 410 ++++++++++++++++++ src/handlers/lidarr_handlers/library/mod.rs | 19 +- src/handlers/lidarr_handlers/mod.rs | 6 +- src/models/lidarr_models.rs | 14 +- src/models/servarr_data/lidarr/lidarr_data.rs | 44 +- .../servarr_data/lidarr/lidarr_data_tests.rs | 19 +- src/models/servarr_data/mod.rs | 2 +- .../lidarr_downloads_network_tests.rs | 43 ++ src/network/lidarr_network/downloads/mod.rs | 40 ++ .../library/lidarr_library_network_tests.rs | 27 +- src/network/lidarr_network/library/mod.rs | 34 +- .../lidarr_network/lidarr_network_tests.rs | 113 ++++- src/network/lidarr_network/mod.rs | 68 ++- .../lidarr_root_folders_network_tests.rs | 39 ++ .../lidarr_network/root_folders/mod.rs | 29 ++ .../system/lidarr_system_network_tests.rs | 176 +------- src/network/lidarr_network/system/mod.rs | 107 +---- src/ui/lidarr_ui/library/delete_artist_ui.rs | 57 +++ .../library/delete_artist_ui_tests.rs | 44 ++ src/ui/lidarr_ui/library/library_ui_tests.rs | 245 ++++++++++- src/ui/lidarr_ui/library/mod.rs | 10 +- ...elete_artist_ui_renders_delete_artist.snap | 38 ++ ...ui_renders_delete_artist_over_library.snap | 38 ++ ...pshot_tests__library_ui_renders_empty.snap | 5 + ...hot_tests__library_ui_renders_loading.snap | 8 + ...napshot_tests__lidarr_library_Artists.snap | 7 + ...sts__lidarr_library_ArtistsSortPrompt.snap | 42 ++ ...t_tests__lidarr_library_FilterArtists.snap | 28 ++ ...ts__lidarr_library_FilterArtistsError.snap | 31 ++ ...t_tests__lidarr_library_SearchArtists.snap | 28 ++ ...ts__lidarr_library_SearchArtistsError.snap | 31 ++ 43 files changed, 1995 insertions(+), 332 deletions(-) create mode 100644 src/cli/lidarr/delete_command_handler.rs create mode 100644 src/cli/lidarr/delete_command_handler_tests.rs create mode 100644 src/handlers/lidarr_handlers/library/delete_artist_handler.rs create mode 100644 src/handlers/lidarr_handlers/library/delete_artist_handler_tests.rs create mode 100644 src/network/lidarr_network/downloads/lidarr_downloads_network_tests.rs create mode 100644 src/network/lidarr_network/downloads/mod.rs create mode 100644 src/network/lidarr_network/root_folders/lidarr_root_folders_network_tests.rs create mode 100644 src/network/lidarr_network/root_folders/mod.rs create mode 100644 src/ui/lidarr_ui/library/delete_artist_ui.rs create mode 100644 src/ui/lidarr_ui/library/delete_artist_ui_tests.rs create mode 100644 src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__delete_artist_ui__delete_artist_ui_tests__tests__snapshot_tests__delete_artist_ui_renders_delete_artist.snap create mode 100644 src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_delete_artist_over_library.snap create mode 100644 src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_empty.snap create mode 100644 src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_loading.snap create mode 100644 src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__lidarr_library_Artists.snap create mode 100644 src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__lidarr_library_ArtistsSortPrompt.snap create mode 100644 src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__lidarr_library_FilterArtists.snap create mode 100644 src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__lidarr_library_FilterArtistsError.snap create mode 100644 src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__lidarr_library_SearchArtists.snap create mode 100644 src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__lidarr_library_SearchArtistsError.snap diff --git a/src/app/lidarr/lidarr_context_clues.rs b/src/app/lidarr/lidarr_context_clues.rs index a2cdabe..772cb27 100644 --- a/src/app/lidarr/lidarr_context_clues.rs +++ b/src/app/lidarr/lidarr_context_clues.rs @@ -1,11 +1,24 @@ use crate::app::App; use crate::app::context_clues::{ContextClue, ContextClueProvider}; +use crate::app::key_binding::DEFAULT_KEYBINDINGS; use crate::models::Route; #[cfg(test)] #[path = "lidarr_context_clues_tests.rs"] mod lidarr_context_clues_tests; +pub static ARTISTS_CONTEXT_CLUES: [ContextClue; 6] = [ + (DEFAULT_KEYBINDINGS.sort, DEFAULT_KEYBINDINGS.sort.desc), + (DEFAULT_KEYBINDINGS.delete, DEFAULT_KEYBINDINGS.delete.desc), + (DEFAULT_KEYBINDINGS.search, DEFAULT_KEYBINDINGS.search.desc), + (DEFAULT_KEYBINDINGS.filter, DEFAULT_KEYBINDINGS.filter.desc), + ( + DEFAULT_KEYBINDINGS.refresh, + DEFAULT_KEYBINDINGS.refresh.desc, + ), + (DEFAULT_KEYBINDINGS.esc, "cancel filter"), +]; + pub(in crate::app) struct LidarrContextClueProvider; impl ContextClueProvider for LidarrContextClueProvider { diff --git a/src/app/lidarr/lidarr_context_clues_tests.rs b/src/app/lidarr/lidarr_context_clues_tests.rs index 6f7f177..75e4193 100644 --- a/src/app/lidarr/lidarr_context_clues_tests.rs +++ b/src/app/lidarr/lidarr_context_clues_tests.rs @@ -2,10 +2,10 @@ mod tests { use crate::app::context_clues::ContextClueProvider; use crate::app::key_binding::DEFAULT_KEYBINDINGS; - use crate::app::lidarr::lidarr_context_clues::LidarrContextClueProvider; + use crate::app::lidarr::lidarr_context_clues::{LidarrContextClueProvider, ARTISTS_CONTEXT_CLUES}; use crate::app::App; use crate::models::servarr_data::lidarr::lidarr_data::{ - ActiveLidarrBlock, ARTISTS_CONTEXT_CLUES, + ActiveLidarrBlock, }; use crate::models::servarr_data::radarr::radarr_data::ActiveRadarrBlock; @@ -17,6 +17,10 @@ mod tests { artists_context_clues_iter.next(), &(DEFAULT_KEYBINDINGS.sort, DEFAULT_KEYBINDINGS.sort.desc) ); + assert_some_eq_x!( + artists_context_clues_iter.next(), + &(DEFAULT_KEYBINDINGS.delete, DEFAULT_KEYBINDINGS.delete.desc) + ); assert_some_eq_x!( artists_context_clues_iter.next(), &(DEFAULT_KEYBINDINGS.search, DEFAULT_KEYBINDINGS.search.desc) diff --git a/src/app/lidarr/lidarr_tests.rs b/src/app/lidarr/lidarr_tests.rs index 75b282e..60fe4c0 100644 --- a/src/app/lidarr/lidarr_tests.rs +++ b/src/app/lidarr/lidarr_tests.rs @@ -1,5 +1,6 @@ #[cfg(test)] mod tests { + use pretty_assertions::assert_eq; use crate::app::App; use crate::models::servarr_data::lidarr::lidarr_data::ActiveLidarrBlock; use crate::network::NetworkEvent; @@ -14,6 +15,7 @@ mod tests { app.dispatch_by_lidarr_block(&ActiveLidarrBlock::Artists).await; + assert!(app.is_loading); assert_eq!( rx.recv().await.unwrap(), LidarrEvent::GetQualityProfiles.into() @@ -24,5 +26,7 @@ mod tests { ); assert_eq!(rx.recv().await.unwrap(), LidarrEvent::GetTags.into()); assert_eq!(rx.recv().await.unwrap(), LidarrEvent::ListArtists.into()); + assert!(!app.data.sonarr_data.prompt_confirm); + assert_eq!(app.tick_count, 0); } } diff --git a/src/app/lidarr/mod.rs b/src/app/lidarr/mod.rs index 4caaaea..716376f 100644 --- a/src/app/lidarr/mod.rs +++ b/src/app/lidarr/mod.rs @@ -5,7 +5,7 @@ use crate::{ use super::App; -pub(in crate::app) mod lidarr_context_clues; +pub mod lidarr_context_clues; #[cfg(test)] #[path = "lidarr_tests.rs"] diff --git a/src/app/mod.rs b/src/app/mod.rs index c40e0fb..7fcd297 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -26,9 +26,9 @@ mod app_tests; pub mod context_clues; pub mod key_binding; mod key_binding_tests; -pub mod lidarr; pub mod radarr; pub mod sonarr; +pub mod lidarr; pub struct App<'a> { navigation_stack: Vec, diff --git a/src/app/sonarr/sonarr_context_clues_tests.rs b/src/app/sonarr/sonarr_context_clues_tests.rs index 30b953d..08aa67f 100644 --- a/src/app/sonarr/sonarr_context_clues_tests.rs +++ b/src/app/sonarr/sonarr_context_clues_tests.rs @@ -455,7 +455,6 @@ mod tests { let mut app = App::test_default(); app.push_navigation_stack(ActiveRadarrBlock::default().into()); - // This should panic because the route is not a Sonarr route SonarrContextClueProvider::get_context_clues(&mut app); } diff --git a/src/cli/cli_tests.rs b/src/cli/cli_tests.rs index 7232497..2beb319 100644 --- a/src/cli/cli_tests.rs +++ b/src/cli/cli_tests.rs @@ -2,18 +2,16 @@ mod tests { use std::sync::Arc; - use clap::{CommandFactory, error::ErrorKind}; + use clap::{error::ErrorKind, CommandFactory}; use mockall::predicate::eq; use rstest::rstest; use serde_json::json; use tokio::sync::Mutex; use crate::{ - Cli, app::App, cli::{handle_command, mutex_flags_or_option, radarr::RadarrCommand, sonarr::SonarrCommand}, models::{ - Serdeable, radarr_models::{ BlocklistItem as RadarrBlocklistItem, BlocklistResponse as RadarrBlocklistResponse, RadarrSerdeable, @@ -22,10 +20,12 @@ mod tests { BlocklistItem as SonarrBlocklistItem, BlocklistResponse as SonarrBlocklistResponse, SonarrSerdeable, }, + Serdeable, }, network::{ - MockNetworkTrait, NetworkEvent, radarr_network::RadarrEvent, sonarr_network::SonarrEvent, + radarr_network::RadarrEvent, sonarr_network::SonarrEvent, MockNetworkTrait, NetworkEvent, }, + Cli, }; use pretty_assertions::assert_eq; @@ -55,6 +55,13 @@ mod tests { assert_ok!(&result); } + #[test] + fn test_lidarr_subcommand_delegates_to_lidarr() { + let result = Cli::command().try_get_matches_from(["managarr", "lidarr", "list", "artists"]); + + assert_ok!(&result); + } + #[test] fn test_completions_requires_argument() { let result = Cli::command().try_get_matches_from(["managarr", "completions"]); @@ -174,4 +181,6 @@ mod tests { assert_ok!(&result); } + + // TODO: Implement test_cli_handler_delegates_lidarr_commands_to_the_lidarr_cli_handler } diff --git a/src/cli/lidarr/delete_command_handler.rs b/src/cli/lidarr/delete_command_handler.rs new file mode 100644 index 0000000..131b5aa --- /dev/null +++ b/src/cli/lidarr/delete_command_handler.rs @@ -0,0 +1,80 @@ +use std::sync::Arc; + +use anyhow::Result; +use clap::Subcommand; +use tokio::sync::Mutex; + +use crate::{ + app::App, + cli::{CliCommandHandler, Command}, + models::lidarr_models::DeleteArtistParams, + network::{NetworkTrait, lidarr_network::LidarrEvent}, +}; + +use super::LidarrCommand; + +#[cfg(test)] +#[path = "delete_command_handler_tests.rs"] +mod delete_command_handler_tests; + +#[derive(Debug, Clone, PartialEq, Eq, Subcommand)] +pub enum LidarrDeleteCommand { + #[command(about = "Delete an artist from your Lidarr library")] + Artist { + #[arg(long, help = "The ID of the artist to delete", required = true)] + artist_id: i64, + #[arg(long, help = "Delete the artist files from disk as well")] + delete_files_from_disk: bool, + #[arg(long, help = "Add a list exclusion for this artist")] + add_list_exclusion: bool, + }, +} + +impl From for Command { + fn from(value: LidarrDeleteCommand) -> Self { + Command::Lidarr(LidarrCommand::Delete(value)) + } +} + +pub(super) struct LidarrDeleteCommandHandler<'a, 'b> { + _app: &'a Arc>>, + command: LidarrDeleteCommand, + network: &'a mut dyn NetworkTrait, +} + +impl<'a, 'b> CliCommandHandler<'a, 'b, LidarrDeleteCommand> for LidarrDeleteCommandHandler<'a, 'b> { + fn with( + _app: &'a Arc>>, + command: LidarrDeleteCommand, + network: &'a mut dyn NetworkTrait, + ) -> Self { + LidarrDeleteCommandHandler { + _app, + command, + network, + } + } + + async fn handle(self) -> Result { + let result = match self.command { + LidarrDeleteCommand::Artist { + artist_id, + delete_files_from_disk, + add_list_exclusion, + } => { + let delete_artist_params = DeleteArtistParams { + id: artist_id, + delete_files: delete_files_from_disk, + add_import_list_exclusion: add_list_exclusion, + }; + let resp = self + .network + .handle_network_event(LidarrEvent::DeleteArtist(delete_artist_params).into()) + .await?; + serde_json::to_string_pretty(&resp)? + } + }; + + Ok(result) + } +} diff --git a/src/cli/lidarr/delete_command_handler_tests.rs b/src/cli/lidarr/delete_command_handler_tests.rs new file mode 100644 index 0000000..f36df29 --- /dev/null +++ b/src/cli/lidarr/delete_command_handler_tests.rs @@ -0,0 +1,145 @@ +#[cfg(test)] +mod tests { + use crate::{ + Cli, + cli::{ + Command, + lidarr::{LidarrCommand, delete_command_handler::LidarrDeleteCommand}, + }, + }; + use clap::{CommandFactory, Parser, error::ErrorKind}; + use pretty_assertions::assert_eq; + + #[test] + fn test_lidarr_delete_command_from() { + let command = LidarrDeleteCommand::Artist { + artist_id: 1, + delete_files_from_disk: false, + add_list_exclusion: false, + }; + + let result = Command::from(command.clone()); + + assert_eq!(result, Command::Lidarr(LidarrCommand::Delete(command))); + } + + mod cli { + use super::*; + use pretty_assertions::assert_eq; + + #[test] + fn test_delete_artist_requires_arguments() { + let result = + Cli::command().try_get_matches_from(["managarr", "lidarr", "delete", "artist"]); + + assert_err!(&result); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_delete_artist_defaults() { + let expected_args = LidarrDeleteCommand::Artist { + artist_id: 1, + delete_files_from_disk: false, + add_list_exclusion: false, + }; + + let result = + Cli::try_parse_from(["managarr", "lidarr", "delete", "artist", "--artist-id", "1"]); + + assert_ok!(&result); + + let Some(Command::Lidarr(LidarrCommand::Delete(delete_command))) = result.unwrap().command + else { + panic!("Unexpected command type"); + }; + assert_eq!(delete_command, expected_args); + } + + #[test] + fn test_delete_artist_all_args_defined() { + let expected_args = LidarrDeleteCommand::Artist { + artist_id: 1, + delete_files_from_disk: true, + add_list_exclusion: true, + }; + + let result = Cli::try_parse_from([ + "managarr", + "lidarr", + "delete", + "artist", + "--artist-id", + "1", + "--delete-files-from-disk", + "--add-list-exclusion", + ]); + + assert_ok!(&result); + + let Some(Command::Lidarr(LidarrCommand::Delete(delete_command))) = result.unwrap().command + else { + panic!("Unexpected command type"); + }; + assert_eq!(delete_command, expected_args); + } + } + + mod handler { + use std::sync::Arc; + + use mockall::predicate::eq; + use serde_json::json; + use tokio::sync::Mutex; + + use crate::{ + app::App, + cli::{ + CliCommandHandler, + lidarr::delete_command_handler::{LidarrDeleteCommand, LidarrDeleteCommandHandler}, + }, + models::{ + Serdeable, + lidarr_models::{DeleteArtistParams, LidarrSerdeable}, + }, + network::{MockNetworkTrait, NetworkEvent, lidarr_network::LidarrEvent}, + }; + + #[tokio::test] + async fn test_handle_delete_artist_command() { + let expected_delete_artist_params = DeleteArtistParams { + id: 1, + delete_files: true, + add_import_list_exclusion: true, + }; + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::( + LidarrEvent::DeleteArtist(expected_delete_artist_params).into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Lidarr(LidarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::test_default())); + let delete_artist_command = LidarrDeleteCommand::Artist { + artist_id: 1, + delete_files_from_disk: true, + add_list_exclusion: true, + }; + + let result = + LidarrDeleteCommandHandler::with(&app_arc, delete_artist_command, &mut mock_network) + .handle() + .await; + + assert_ok!(&result); + } + } +} diff --git a/src/cli/lidarr/lidarr_command_tests.rs b/src/cli/lidarr/lidarr_command_tests.rs index bf0ffaa..653f6d4 100644 --- a/src/cli/lidarr/lidarr_command_tests.rs +++ b/src/cli/lidarr/lidarr_command_tests.rs @@ -33,5 +33,92 @@ mod tests { assert_err!(&result); } + + #[test] + fn test_lidarr_delete_subcommand_requires_subcommand() { + let result = Cli::command().try_get_matches_from(["managarr", "lidarr", "delete"]); + + assert_err!(&result); + } + } + + mod handler { + use std::sync::Arc; + + use mockall::predicate::eq; + use serde_json::json; + use tokio::sync::Mutex; + + use crate::{ + app::App, + cli::{ + CliCommandHandler, + lidarr::{ + LidarrCliHandler, LidarrCommand, + delete_command_handler::LidarrDeleteCommand, + list_command_handler::LidarrListCommand, + }, + }, + models::{ + Serdeable, + lidarr_models::{Artist, DeleteArtistParams, LidarrSerdeable}, + }, + network::{MockNetworkTrait, NetworkEvent, lidarr_network::LidarrEvent}, + }; + + #[tokio::test] + async fn test_lidarr_cli_handler_delegates_delete_commands_to_the_delete_command_handler() { + let expected_delete_artist_params = DeleteArtistParams { + id: 1, + delete_files: true, + add_import_list_exclusion: true, + }; + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::( + LidarrEvent::DeleteArtist(expected_delete_artist_params).into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Lidarr(LidarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::test_default())); + let delete_artist_command = LidarrCommand::Delete(LidarrDeleteCommand::Artist { + artist_id: 1, + delete_files_from_disk: true, + add_list_exclusion: true, + }); + + let result = LidarrCliHandler::with(&app_arc, delete_artist_command, &mut mock_network) + .handle() + .await; + + assert_ok!(&result); + } + + #[tokio::test] + async fn test_lidarr_cli_handler_delegates_list_commands_to_the_list_command_handler() { + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::(LidarrEvent::ListArtists.into())) + .times(1) + .returning(|_| { + Ok(Serdeable::Lidarr(LidarrSerdeable::Artists(vec![ + Artist::default(), + ]))) + }); + let app_arc = Arc::new(Mutex::new(App::test_default())); + let list_artists_command = LidarrCommand::List(LidarrListCommand::Artists); + + let result = LidarrCliHandler::with(&app_arc, list_artists_command, &mut mock_network) + .handle() + .await; + + assert_ok!(&result); + } } } diff --git a/src/cli/lidarr/mod.rs b/src/cli/lidarr/mod.rs index 99dab8b..1251bbb 100644 --- a/src/cli/lidarr/mod.rs +++ b/src/cli/lidarr/mod.rs @@ -2,16 +2,15 @@ use std::sync::Arc; use anyhow::Result; use clap::Subcommand; +use delete_command_handler::{LidarrDeleteCommand, LidarrDeleteCommandHandler}; use list_command_handler::{LidarrListCommand, LidarrListCommandHandler}; use tokio::sync::Mutex; -use crate::{ - app::App, - network::NetworkTrait, -}; +use crate::{app::App, network::NetworkTrait}; use super::{CliCommandHandler, Command}; +mod delete_command_handler; mod list_command_handler; #[cfg(test)] @@ -20,6 +19,11 @@ mod lidarr_command_tests; #[derive(Debug, Clone, PartialEq, Eq, Subcommand)] pub enum LidarrCommand { + #[command( + subcommand, + about = "Commands to delete resources from your Lidarr instance" + )] + Delete(LidarrDeleteCommand), #[command( subcommand, about = "Commands to list attributes from your Lidarr instance" @@ -54,6 +58,11 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, LidarrCommand> for LidarrCliHandler<'a, ' async fn handle(self) -> Result { let result = match self.command { + LidarrCommand::Delete(delete_command) => { + LidarrDeleteCommandHandler::with(self.app, delete_command, self.network) + .handle() + .await? + } LidarrCommand::List(list_command) => { LidarrListCommandHandler::with(self.app, list_command, self.network) .handle() diff --git a/src/handlers/lidarr_handlers/library/delete_artist_handler.rs b/src/handlers/lidarr_handlers/library/delete_artist_handler.rs new file mode 100644 index 0000000..48c8251 --- /dev/null +++ b/src/handlers/lidarr_handlers/library/delete_artist_handler.rs @@ -0,0 +1,149 @@ +use crate::models::lidarr_models::DeleteArtistParams; +use crate::network::lidarr_network::LidarrEvent; +use crate::{ + app::App, + event::Key, + handlers::{KeyEventHandler, handle_prompt_toggle}, + matches_key, + models::servarr_data::lidarr::lidarr_data::{ActiveLidarrBlock, DELETE_ARTIST_BLOCKS}, +}; + +#[cfg(test)] +#[path = "delete_artist_handler_tests.rs"] +mod delete_artist_handler_tests; + +pub(in crate::handlers::lidarr_handlers) struct DeleteArtistHandler<'a, 'b> { + key: Key, + app: &'a mut App<'b>, + active_lidarr_block: ActiveLidarrBlock, + _context: Option, +} + +impl DeleteArtistHandler<'_, '_> { + fn build_delete_artist_params(&mut self) -> DeleteArtistParams { + let id = self.app.data.lidarr_data.artists.current_selection().id; + let delete_files = self.app.data.lidarr_data.delete_artist_files; + let add_import_list_exclusion = self.app.data.lidarr_data.add_import_list_exclusion; + self.app.data.lidarr_data.reset_delete_artist_preferences(); + + DeleteArtistParams { + id, + delete_files, + add_import_list_exclusion, + } + } +} + +impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveLidarrBlock> for DeleteArtistHandler<'a, 'b> { + fn accepts(active_block: ActiveLidarrBlock) -> bool { + DELETE_ARTIST_BLOCKS.contains(&active_block) + } + + fn ignore_special_keys(&self) -> bool { + self.app.ignore_special_keys_for_textbox_input + } + + fn new( + key: Key, + app: &'a mut App<'b>, + active_block: ActiveLidarrBlock, + _context: Option, + ) -> Self { + DeleteArtistHandler { + key, + app, + active_lidarr_block: active_block, + _context, + } + } + + fn get_key(&self) -> Key { + self.key + } + + fn is_ready(&self) -> bool { + !self.app.is_loading + } + + fn handle_scroll_up(&mut self) { + if self.active_lidarr_block == ActiveLidarrBlock::DeleteArtistPrompt { + self.app.data.lidarr_data.selected_block.up(); + } + } + + fn handle_scroll_down(&mut self) { + if self.active_lidarr_block == ActiveLidarrBlock::DeleteArtistPrompt { + self.app.data.lidarr_data.selected_block.down(); + } + } + + fn handle_home(&mut self) {} + + fn handle_end(&mut self) {} + + fn handle_delete(&mut self) {} + + fn handle_left_right_action(&mut self) { + if self.active_lidarr_block == ActiveLidarrBlock::DeleteArtistPrompt { + handle_prompt_toggle(self.app, self.key); + } + } + + fn handle_submit(&mut self) { + if self.active_lidarr_block == ActiveLidarrBlock::DeleteArtistPrompt { + match self.app.data.lidarr_data.selected_block.get_active_block() { + ActiveLidarrBlock::DeleteArtistConfirmPrompt => { + if self.app.data.lidarr_data.prompt_confirm { + self.app.data.lidarr_data.prompt_confirm_action = + Some(LidarrEvent::DeleteArtist(self.build_delete_artist_params())); + self.app.should_refresh = true; + } else { + self.app.data.lidarr_data.reset_delete_artist_preferences(); + } + + self.app.pop_navigation_stack(); + } + ActiveLidarrBlock::DeleteArtistToggleDeleteFile => { + self.app.data.lidarr_data.delete_artist_files = + !self.app.data.lidarr_data.delete_artist_files; + } + ActiveLidarrBlock::DeleteArtistToggleAddListExclusion => { + self.app.data.lidarr_data.add_import_list_exclusion = + !self.app.data.lidarr_data.add_import_list_exclusion; + } + _ => (), + } + } + } + + fn handle_esc(&mut self) { + if self.active_lidarr_block == ActiveLidarrBlock::DeleteArtistPrompt { + self.app.pop_navigation_stack(); + self.app.data.lidarr_data.reset_delete_artist_preferences(); + self.app.data.lidarr_data.prompt_confirm = false; + } + } + + fn handle_char_key_event(&mut self) { + if self.active_lidarr_block == ActiveLidarrBlock::DeleteArtistPrompt + && self.app.data.lidarr_data.selected_block.get_active_block() + == ActiveLidarrBlock::DeleteArtistConfirmPrompt + && matches_key!(confirm, self.key) + { + self.app.data.lidarr_data.prompt_confirm = true; + self.app.data.lidarr_data.prompt_confirm_action = + Some(LidarrEvent::DeleteArtist(self.build_delete_artist_params())); + self.app.should_refresh = true; + + self.app.pop_navigation_stack(); + } + } + + fn app_mut(&mut self) -> &mut App<'b> { + self.app + } + + fn current_route(&self) -> crate::models::Route { + self.app.get_current_route() + } +} diff --git a/src/handlers/lidarr_handlers/library/delete_artist_handler_tests.rs b/src/handlers/lidarr_handlers/library/delete_artist_handler_tests.rs new file mode 100644 index 0000000..64b50fe --- /dev/null +++ b/src/handlers/lidarr_handlers/library/delete_artist_handler_tests.rs @@ -0,0 +1,410 @@ +#[cfg(test)] +mod tests { + use pretty_assertions::assert_eq; + use rstest::rstest; + use strum::IntoEnumIterator; + + use crate::app::App; + use crate::app::key_binding::DEFAULT_KEYBINDINGS; + use crate::event::Key; + use crate::handlers::KeyEventHandler; + use crate::handlers::lidarr_handlers::library::delete_artist_handler::DeleteArtistHandler; + use crate::models::lidarr_models::{Artist, DeleteArtistParams}; + use crate::models::servarr_data::lidarr::lidarr_data::{ActiveLidarrBlock, DELETE_ARTIST_BLOCKS}; + + mod test_handle_scroll_up_and_down { + use pretty_assertions::assert_eq; + use rstest::rstest; + + use crate::models::BlockSelectionState; + use crate::models::servarr_data::lidarr::lidarr_data::DELETE_ARTIST_SELECTION_BLOCKS; + + use super::*; + + #[rstest] + fn test_delete_artist_prompt_scroll(#[values(Key::Up, Key::Down)] key: Key) { + let mut app = App::test_default(); + app.data.lidarr_data.selected_block = + BlockSelectionState::new(DELETE_ARTIST_SELECTION_BLOCKS); + app.data.lidarr_data.selected_block.down(); + + DeleteArtistHandler::new(key, &mut app, ActiveLidarrBlock::DeleteArtistPrompt, None).handle(); + + if key == Key::Up { + assert_eq!( + app.data.lidarr_data.selected_block.get_active_block(), + ActiveLidarrBlock::DeleteArtistToggleDeleteFile + ); + } else { + assert_eq!( + app.data.lidarr_data.selected_block.get_active_block(), + ActiveLidarrBlock::DeleteArtistConfirmPrompt + ); + } + } + + #[rstest] + fn test_delete_artist_prompt_scroll_no_op_when_not_ready( + #[values(Key::Up, Key::Down)] key: Key, + ) { + let mut app = App::test_default(); + app.is_loading = true; + app.data.lidarr_data.selected_block = + BlockSelectionState::new(DELETE_ARTIST_SELECTION_BLOCKS); + app.data.lidarr_data.selected_block.down(); + + DeleteArtistHandler::new(key, &mut app, ActiveLidarrBlock::DeleteArtistPrompt, None).handle(); + + assert_eq!( + app.data.lidarr_data.selected_block.get_active_block(), + ActiveLidarrBlock::DeleteArtistToggleAddListExclusion + ); + } + } + + mod test_handle_left_right_action { + use rstest::rstest; + + use super::*; + + #[rstest] + fn test_left_right_prompt_toggle(#[values(Key::Left, Key::Right)] key: Key) { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveLidarrBlock::DeleteArtistPrompt.into()); + + DeleteArtistHandler::new(key, &mut app, ActiveLidarrBlock::DeleteArtistPrompt, None).handle(); + + assert!(app.data.lidarr_data.prompt_confirm); + + DeleteArtistHandler::new(key, &mut app, ActiveLidarrBlock::DeleteArtistPrompt, None).handle(); + + assert!(!app.data.lidarr_data.prompt_confirm); + } + } + + mod test_handle_submit { + use pretty_assertions::assert_eq; + + use crate::models::BlockSelectionState; + use crate::models::servarr_data::lidarr::lidarr_data::DELETE_ARTIST_SELECTION_BLOCKS; + use crate::network::lidarr_network::LidarrEvent; + + use super::*; + use crate::assert_navigation_popped; + + const SUBMIT_KEY: Key = DEFAULT_KEYBINDINGS.submit.key; + + #[test] + fn test_delete_artist_prompt_prompt_decline_submit() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveLidarrBlock::Artists.into()); + app.push_navigation_stack(ActiveLidarrBlock::DeleteArtistPrompt.into()); + app.data.lidarr_data.selected_block = + BlockSelectionState::new(DELETE_ARTIST_SELECTION_BLOCKS); + app + .data + .lidarr_data + .selected_block + .set_index(0, DELETE_ARTIST_SELECTION_BLOCKS.len() - 1); + app.data.lidarr_data.delete_artist_files = true; + app.data.lidarr_data.add_import_list_exclusion = true; + + DeleteArtistHandler::new( + SUBMIT_KEY, + &mut app, + ActiveLidarrBlock::DeleteArtistPrompt, + None, + ) + .handle(); + + assert_navigation_popped!(app, ActiveLidarrBlock::Artists.into()); + assert_none!(app.data.lidarr_data.prompt_confirm_action); + assert!(!app.data.lidarr_data.prompt_confirm); + assert!(!app.data.lidarr_data.delete_artist_files); + assert!(!app.data.lidarr_data.add_import_list_exclusion); + } + + #[test] + fn test_delete_artist_confirm_prompt_prompt_confirmation_submit() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveLidarrBlock::Artists.into()); + app.push_navigation_stack(ActiveLidarrBlock::DeleteArtistPrompt.into()); + app.data.lidarr_data.prompt_confirm = true; + app.data.lidarr_data.delete_artist_files = true; + app.data.lidarr_data.add_import_list_exclusion = true; + app + .data + .lidarr_data + .artists + .set_items(vec![Artist::default()]); + let expected_delete_artist_params = DeleteArtistParams { + id: 0, + delete_files: true, + add_import_list_exclusion: true, + }; + app.data.lidarr_data.selected_block = + BlockSelectionState::new(DELETE_ARTIST_SELECTION_BLOCKS); + app + .data + .lidarr_data + .selected_block + .set_index(0, DELETE_ARTIST_SELECTION_BLOCKS.len() - 1); + + DeleteArtistHandler::new( + SUBMIT_KEY, + &mut app, + ActiveLidarrBlock::DeleteArtistPrompt, + None, + ) + .handle(); + + assert_navigation_popped!(app, ActiveLidarrBlock::Artists.into()); + assert_eq!( + app.data.lidarr_data.prompt_confirm_action, + Some(LidarrEvent::DeleteArtist(expected_delete_artist_params)) + ); + assert!(app.should_refresh); + assert!(app.data.lidarr_data.prompt_confirm); + assert!(!app.data.lidarr_data.delete_artist_files); + assert!(!app.data.lidarr_data.add_import_list_exclusion); + } + + #[test] + fn test_delete_artist_confirm_prompt_prompt_confirmation_submit_no_op_when_not_ready() { + let mut app = App::test_default(); + app.is_loading = true; + app.push_navigation_stack(ActiveLidarrBlock::Artists.into()); + app.push_navigation_stack(ActiveLidarrBlock::DeleteArtistPrompt.into()); + app.data.lidarr_data.prompt_confirm = true; + app.data.lidarr_data.delete_artist_files = true; + app.data.lidarr_data.add_import_list_exclusion = true; + + DeleteArtistHandler::new( + SUBMIT_KEY, + &mut app, + ActiveLidarrBlock::DeleteArtistPrompt, + None, + ) + .handle(); + + assert_eq!( + app.get_current_route(), + ActiveLidarrBlock::DeleteArtistPrompt.into() + ); + assert_none!(app.data.lidarr_data.prompt_confirm_action); + assert!(!app.should_refresh); + assert!(app.data.lidarr_data.prompt_confirm); + assert!(app.data.lidarr_data.delete_artist_files); + assert!(app.data.lidarr_data.add_import_list_exclusion); + } + + #[test] + fn test_delete_artist_toggle_delete_files_submit() { + let current_route = ActiveLidarrBlock::DeleteArtistPrompt.into(); + let mut app = App::test_default(); + app.data.lidarr_data.selected_block = + BlockSelectionState::new(DELETE_ARTIST_SELECTION_BLOCKS); + app.push_navigation_stack(ActiveLidarrBlock::DeleteArtistPrompt.into()); + + DeleteArtistHandler::new( + SUBMIT_KEY, + &mut app, + ActiveLidarrBlock::DeleteArtistPrompt, + None, + ) + .handle(); + + assert_eq!(app.get_current_route(), current_route); + assert_eq!(app.data.lidarr_data.delete_artist_files, true); + + DeleteArtistHandler::new( + SUBMIT_KEY, + &mut app, + ActiveLidarrBlock::DeleteArtistPrompt, + None, + ) + .handle(); + + assert_eq!(app.get_current_route(), current_route); + assert_eq!(app.data.lidarr_data.delete_artist_files, false); + } + } + + mod test_handle_esc { + use super::*; + use crate::assert_navigation_popped; + use rstest::rstest; + + const ESC_KEY: Key = DEFAULT_KEYBINDINGS.esc.key; + + #[rstest] + fn test_delete_artist_prompt_esc(#[values(true, false)] is_ready: bool) { + let mut app = App::test_default(); + app.is_loading = is_ready; + app.push_navigation_stack(ActiveLidarrBlock::Artists.into()); + app.push_navigation_stack(ActiveLidarrBlock::DeleteArtistPrompt.into()); + app.data.lidarr_data.prompt_confirm = true; + app.data.lidarr_data.delete_artist_files = true; + app.data.lidarr_data.add_import_list_exclusion = true; + + DeleteArtistHandler::new( + ESC_KEY, + &mut app, + ActiveLidarrBlock::DeleteArtistPrompt, + None, + ) + .handle(); + + assert_navigation_popped!(app, ActiveLidarrBlock::Artists.into()); + assert!(!app.data.lidarr_data.prompt_confirm); + assert!(!app.data.lidarr_data.delete_artist_files); + assert!(!app.data.lidarr_data.add_import_list_exclusion); + } + } + + mod test_handle_key_char { + use crate::{ + assert_navigation_popped, + models::{ + BlockSelectionState, servarr_data::lidarr::lidarr_data::DELETE_ARTIST_SELECTION_BLOCKS, + }, + network::lidarr_network::LidarrEvent, + }; + use pretty_assertions::assert_eq; + + use super::*; + + #[test] + fn test_delete_artist_confirm_prompt_prompt_confirm() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveLidarrBlock::Artists.into()); + app.push_navigation_stack(ActiveLidarrBlock::DeleteArtistPrompt.into()); + app.data.lidarr_data.delete_artist_files = true; + app.data.lidarr_data.add_import_list_exclusion = true; + app + .data + .lidarr_data + .artists + .set_items(vec![Artist::default()]); + let expected_delete_artist_params = DeleteArtistParams { + id: 0, + delete_files: true, + add_import_list_exclusion: true, + }; + app.data.lidarr_data.selected_block = + BlockSelectionState::new(DELETE_ARTIST_SELECTION_BLOCKS); + app + .data + .lidarr_data + .selected_block + .set_index(0, DELETE_ARTIST_SELECTION_BLOCKS.len() - 1); + + DeleteArtistHandler::new( + DEFAULT_KEYBINDINGS.confirm.key, + &mut app, + ActiveLidarrBlock::DeleteArtistPrompt, + None, + ) + .handle(); + + assert_navigation_popped!(app, ActiveLidarrBlock::Artists.into()); + assert_eq!( + app.data.lidarr_data.prompt_confirm_action, + Some(LidarrEvent::DeleteArtist(expected_delete_artist_params)) + ); + assert!(app.should_refresh); + assert!(app.data.lidarr_data.prompt_confirm); + assert!(!app.data.lidarr_data.delete_artist_files); + assert!(!app.data.lidarr_data.add_import_list_exclusion); + } + } + + #[test] + fn test_delete_artist_handler_accepts() { + ActiveLidarrBlock::iter().for_each(|active_lidarr_block| { + if DELETE_ARTIST_BLOCKS.contains(&active_lidarr_block) { + assert!(DeleteArtistHandler::accepts(active_lidarr_block)); + } else { + assert!(!DeleteArtistHandler::accepts(active_lidarr_block)); + } + }); + } + + #[rstest] + fn test_delete_artist_handler_ignore_special_keys( + #[values(true, false)] ignore_special_keys_for_textbox_input: bool, + ) { + let mut app = App::test_default(); + app.ignore_special_keys_for_textbox_input = ignore_special_keys_for_textbox_input; + let handler = DeleteArtistHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveLidarrBlock::default(), + None, + ); + + assert_eq!( + handler.ignore_special_keys(), + ignore_special_keys_for_textbox_input + ); + } + + #[test] + fn test_build_delete_artist_params() { + let mut app = App::test_default(); + app + .data + .lidarr_data + .artists + .set_items(vec![Artist::default()]); + app.data.lidarr_data.delete_artist_files = true; + app.data.lidarr_data.add_import_list_exclusion = true; + let expected_delete_artist_params = DeleteArtistParams { + id: 0, + delete_files: true, + add_import_list_exclusion: true, + }; + + let delete_artist_params = DeleteArtistHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveLidarrBlock::DeleteArtistPrompt, + None, + ) + .build_delete_artist_params(); + + assert_eq!(delete_artist_params, expected_delete_artist_params); + assert!(!app.data.lidarr_data.delete_artist_files); + assert!(!app.data.lidarr_data.add_import_list_exclusion); + } + + #[test] + fn test_delete_artist_handler_not_ready_when_loading() { + let mut app = App::test_default(); + app.is_loading = true; + + let handler = DeleteArtistHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveLidarrBlock::DeleteArtistPrompt, + None, + ); + + assert!(!handler.is_ready()); + } + + #[test] + fn test_delete_artist_handler_ready_when_not_loading() { + let mut app = App::test_default(); + app.is_loading = false; + + let handler = DeleteArtistHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveLidarrBlock::DeleteArtistPrompt, + None, + ); + + assert!(handler.is_ready()); + } +} diff --git a/src/handlers/lidarr_handlers/library/mod.rs b/src/handlers/lidarr_handlers/library/mod.rs index 348fc49..2845c6d 100644 --- a/src/handlers/lidarr_handlers/library/mod.rs +++ b/src/handlers/lidarr_handlers/library/mod.rs @@ -4,8 +4,11 @@ use crate::{ handlers::{KeyEventHandler, handle_clear_errors}, matches_key, models::{ + BlockSelectionState, lidarr_models::Artist, - servarr_data::lidarr::lidarr_data::{ActiveLidarrBlock, LIBRARY_BLOCKS}, + servarr_data::lidarr::lidarr_data::{ + ActiveLidarrBlock, DELETE_ARTIST_SELECTION_BLOCKS, LIBRARY_BLOCKS, + }, stateful_table::SortOption, }, }; @@ -13,6 +16,10 @@ use crate::{ use super::handle_change_tab_left_right_keys; use crate::handlers::table_handler::{TableHandlingConfig, handle_table}; +mod delete_artist_handler; + +pub(in crate::handlers::lidarr_handlers) use delete_artist_handler::DeleteArtistHandler; + #[cfg(test)] #[path = "library_handler_tests.rs"] mod library_handler_tests; @@ -84,7 +91,15 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveLidarrBlock> for LibraryHandler<'a, ' fn handle_end(&mut self) {} - fn handle_delete(&mut self) {} + fn handle_delete(&mut self) { + if self.active_lidarr_block == ActiveLidarrBlock::Artists { + self + .app + .push_navigation_stack(ActiveLidarrBlock::DeleteArtistPrompt.into()); + self.app.data.lidarr_data.selected_block = + BlockSelectionState::new(DELETE_ARTIST_SELECTION_BLOCKS); + } + } fn handle_left_right_action(&mut self) { if self.active_lidarr_block == ActiveLidarrBlock::Artists { diff --git a/src/handlers/lidarr_handlers/mod.rs b/src/handlers/lidarr_handlers/mod.rs index 0002e73..6932737 100644 --- a/src/handlers/lidarr_handlers/mod.rs +++ b/src/handlers/lidarr_handlers/mod.rs @@ -1,4 +1,4 @@ -use library::LibraryHandler; +use library::{DeleteArtistHandler, LibraryHandler}; use crate::{ app::App, event::Key, matches_key, models::servarr_data::lidarr::lidarr_data::ActiveLidarrBlock, @@ -22,6 +22,10 @@ pub(super) struct LidarrHandler<'a, 'b> { impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveLidarrBlock> for LidarrHandler<'a, 'b> { fn handle(&mut self) { match self.active_lidarr_block { + _ if DeleteArtistHandler::accepts(self.active_lidarr_block) => { + DeleteArtistHandler::new(self.key, self.app, self.active_lidarr_block, self.context) + .handle(); + } _ if LibraryHandler::accepts(self.active_lidarr_block) => { LibraryHandler::new(self.key, self.app, self.active_lidarr_block, self.context).handle(); } diff --git a/src/models/lidarr_models.rs b/src/models/lidarr_models.rs index 3ed7747..70ee24d 100644 --- a/src/models/lidarr_models.rs +++ b/src/models/lidarr_models.rs @@ -3,7 +3,7 @@ use derivative::Derivative; use enum_display_style_derive::EnumDisplayStyle; use serde::{Deserialize, Serialize}; use serde_json::{Number, Value}; -use strum::EnumIter; +use strum::{Display, EnumIter}; use super::{HorizontallyScrollableText, Serdeable}; use crate::serde_enum_from; @@ -45,7 +45,7 @@ pub struct Artist { Clone, Copy, Debug, - strum::Display, + Display, EnumDisplayStyle, )] #[serde(rename_all = "camelCase")] @@ -134,7 +134,7 @@ impl Eq for DownloadRecord {} Copy, Debug, EnumIter, - strum::Display, + Display, EnumDisplayStyle, )] #[serde(rename_all = "camelCase")] @@ -167,6 +167,14 @@ pub struct SystemStatus { pub start_time: DateTime, } +#[derive(Serialize, Deserialize, Debug, Default, Clone, Eq, PartialEq)] +#[serde(rename_all = "lowercase")] +pub struct DeleteArtistParams { + pub id: i64, + pub delete_files: bool, + pub add_import_list_exclusion: bool, +} + impl From for Serdeable { fn from(value: LidarrSerdeable) -> Serdeable { Serdeable::Lidarr(value) diff --git a/src/models/servarr_data/lidarr/lidarr_data.rs b/src/models/servarr_data/lidarr/lidarr_data.rs index 2a311c3..144c6d8 100644 --- a/src/models/servarr_data/lidarr/lidarr_data.rs +++ b/src/models/servarr_data/lidarr/lidarr_data.rs @@ -3,7 +3,7 @@ use chrono::{DateTime, Utc}; use strum::EnumIter; #[cfg(test)] use strum::{Display, EnumString}; - +use crate::app::lidarr::lidarr_context_clues::ARTISTS_CONTEXT_CLUES; use crate::models::{ Route, TabRoute, TabState, lidarr_models::{Artist, DownloadRecord}, @@ -17,7 +17,9 @@ use crate::network::lidarr_network::LidarrEvent; mod lidarr_data_tests; pub struct LidarrData<'a> { + pub add_import_list_exclusion: bool, pub artists: StatefulTable, + pub delete_artist_files: bool, pub disk_space_vec: Vec, pub downloads: StatefulTable, pub main_tabs: TabState, @@ -33,15 +35,18 @@ pub struct LidarrData<'a> { } impl LidarrData<'_> { - pub fn reset_sorting(&mut self) { - self.artists.sorting(vec![]); + pub fn reset_delete_artist_preferences(&mut self) { + self.delete_artist_files = false; + self.add_import_list_exclusion = false; } } impl<'a> Default for LidarrData<'a> { fn default() -> LidarrData<'a> { LidarrData { + add_import_list_exclusion: false, artists: StatefulTable::default(), + delete_artist_files: false, disk_space_vec: Vec::new(), downloads: StatefulTable::default(), metadata_profile_map: BiMap::new(), @@ -78,6 +83,8 @@ impl LidarrData<'_> { name: "Name", cmp_fn: Some(|a: &Artist, b: &Artist| a.artist_name.text.cmp(&b.artist_name.text)), }]); + lidarr_data.artists.search = Some("artist search".into()); + lidarr_data.artists.filter = Some("artist filter".into()); lidarr_data.quality_profile_map = BiMap::from_iter([(1i64, "Lossless".to_owned())]); lidarr_data.metadata_profile_map = BiMap::from_iter([(1i64, "Standard".to_owned())]); lidarr_data.tags_map = BiMap::from_iter([(1i64, "usenet".to_owned())]); @@ -93,26 +100,16 @@ impl LidarrData<'_> { } } -use crate::app::context_clues::ContextClue; -use crate::app::key_binding::DEFAULT_KEYBINDINGS; - -pub static ARTISTS_CONTEXT_CLUES: [ContextClue; 5] = [ - (DEFAULT_KEYBINDINGS.sort, DEFAULT_KEYBINDINGS.sort.desc), - (DEFAULT_KEYBINDINGS.search, DEFAULT_KEYBINDINGS.search.desc), - (DEFAULT_KEYBINDINGS.filter, DEFAULT_KEYBINDINGS.filter.desc), - ( - DEFAULT_KEYBINDINGS.refresh, - DEFAULT_KEYBINDINGS.refresh.desc, - ), - (DEFAULT_KEYBINDINGS.esc, "cancel filter"), -]; - #[derive(Clone, Copy, PartialEq, Eq, Debug, Default, EnumIter)] #[cfg_attr(test, derive(Display, EnumString))] pub enum ActiveLidarrBlock { #[default] Artists, ArtistsSortPrompt, + DeleteArtistPrompt, + DeleteArtistConfirmPrompt, + DeleteArtistToggleDeleteFile, + DeleteArtistToggleAddListExclusion, SearchArtists, SearchArtistsError, FilterArtists, @@ -128,6 +125,19 @@ pub static LIBRARY_BLOCKS: [ActiveLidarrBlock; 6] = [ ActiveLidarrBlock::FilterArtistsError, ]; +pub static DELETE_ARTIST_BLOCKS: [ActiveLidarrBlock; 4] = [ + ActiveLidarrBlock::DeleteArtistPrompt, + ActiveLidarrBlock::DeleteArtistConfirmPrompt, + ActiveLidarrBlock::DeleteArtistToggleDeleteFile, + ActiveLidarrBlock::DeleteArtistToggleAddListExclusion, +]; + +pub const DELETE_ARTIST_SELECTION_BLOCKS: &[&[ActiveLidarrBlock]] = &[ + &[ActiveLidarrBlock::DeleteArtistToggleDeleteFile], + &[ActiveLidarrBlock::DeleteArtistToggleAddListExclusion], + &[ActiveLidarrBlock::DeleteArtistConfirmPrompt], +]; + impl From for Route { fn from(active_lidarr_block: ActiveLidarrBlock) -> Route { Route::Lidarr(active_lidarr_block, None) diff --git a/src/models/servarr_data/lidarr/lidarr_data_tests.rs b/src/models/servarr_data/lidarr/lidarr_data_tests.rs index 256b158..d8cb0ef 100644 --- a/src/models/servarr_data/lidarr/lidarr_data_tests.rs +++ b/src/models/servarr_data/lidarr/lidarr_data_tests.rs @@ -2,7 +2,10 @@ mod tests { use pretty_assertions::assert_eq; - use crate::models::{servarr_data::lidarr::lidarr_data::ActiveLidarrBlock, Route}; + use crate::models::{ + servarr_data::lidarr::lidarr_data::{ActiveLidarrBlock, LidarrData}, + Route, + }; #[test] fn test_from_active_lidarr_block_to_route() { @@ -19,4 +22,18 @@ mod tests { Route::Lidarr(ActiveLidarrBlock::Artists, Some(ActiveLidarrBlock::Artists),) ); } + + #[test] + fn test_reset_delete_artist_preferences() { + let mut lidarr_data = LidarrData{ + delete_artist_files: true, + add_import_list_exclusion: true, + ..LidarrData::default() + }; + + lidarr_data.reset_delete_artist_preferences(); + + assert!(!lidarr_data.delete_artist_files); + assert!(!lidarr_data.add_import_list_exclusion); + } } diff --git a/src/models/servarr_data/mod.rs b/src/models/servarr_data/mod.rs index 256f0bc..bdf6f5b 100644 --- a/src/models/servarr_data/mod.rs +++ b/src/models/servarr_data/mod.rs @@ -1,9 +1,9 @@ use crate::models::Route; -pub mod lidarr; pub mod modals; pub mod radarr; pub mod sonarr; +pub mod lidarr; #[cfg(test)] pub(in crate::models::servarr_data) mod data_test_utils; diff --git a/src/network/lidarr_network/downloads/lidarr_downloads_network_tests.rs b/src/network/lidarr_network/downloads/lidarr_downloads_network_tests.rs new file mode 100644 index 0000000..fe31d05 --- /dev/null +++ b/src/network/lidarr_network/downloads/lidarr_downloads_network_tests.rs @@ -0,0 +1,43 @@ +#[cfg(test)] +mod tests { + use crate::models::lidarr_models::{DownloadsResponse, LidarrSerdeable}; + use crate::network::lidarr_network::LidarrEvent; + use crate::network::network_tests::test_utils::{MockServarrApi, test_network}; + use pretty_assertions::assert_eq; + use serde_json::json; + + #[tokio::test] + async fn test_handle_get_downloads_event() { + let downloads_json = json!({ + "records": [{ + "title": "Test Album", + "status": "downloading", + "id": 1, + "size": 100.0, + "sizeleft": 50.0, + "indexer": "test-indexer" + }] + }); + let response: DownloadsResponse = serde_json::from_value(downloads_json.clone()).unwrap(); + let (mock, app, _server) = MockServarrApi::get() + .returns(downloads_json) + .query("pageSize=500") + .build_for(LidarrEvent::GetDownloads(500)) + .await; + app.lock().await.server_tabs.set_index(2); + let mut network = test_network(&app); + + let result = network + .handle_lidarr_event(LidarrEvent::GetDownloads(500)) + .await; + + mock.assert_async().await; + + let LidarrSerdeable::DownloadsResponse(downloads_response) = result.unwrap() else { + panic!("Expected DownloadsResponse"); + }; + + assert_eq!(downloads_response, response); + assert!(!app.lock().await.data.lidarr_data.downloads.is_empty()); + } +} diff --git a/src/network/lidarr_network/downloads/mod.rs b/src/network/lidarr_network/downloads/mod.rs new file mode 100644 index 0000000..3a0e918 --- /dev/null +++ b/src/network/lidarr_network/downloads/mod.rs @@ -0,0 +1,40 @@ +use anyhow::Result; +use log::info; + +use crate::models::lidarr_models::DownloadsResponse; +use crate::network::lidarr_network::LidarrEvent; +use crate::network::{Network, RequestMethod}; + +#[cfg(test)] +#[path = "lidarr_downloads_network_tests.rs"] +mod lidarr_downloads_network_tests; + +impl Network<'_, '_> { + pub(in crate::network::lidarr_network) async fn get_lidarr_downloads( + &mut self, + count: u64, + ) -> Result { + info!("Fetching Lidarr downloads"); + let event = LidarrEvent::GetDownloads(count); + + let request_props = self + .request_props_from( + event, + RequestMethod::Get, + None::<()>, + None, + Some(format!("pageSize={count}")), + ) + .await; + + self + .handle_request::<(), DownloadsResponse>(request_props, |queue_response, mut app| { + app + .data + .lidarr_data + .downloads + .set_items(queue_response.records); + }) + .await + } +} diff --git a/src/network/lidarr_network/library/lidarr_library_network_tests.rs b/src/network/lidarr_network/library/lidarr_library_network_tests.rs index 5f88749..9d6bc36 100644 --- a/src/network/lidarr_network/library/lidarr_library_network_tests.rs +++ b/src/network/lidarr_network/library/lidarr_library_network_tests.rs @@ -1,6 +1,6 @@ #[cfg(test)] mod tests { - use crate::models::lidarr_models::{Artist, LidarrSerdeable}; + use crate::models::lidarr_models::{Artist, DeleteArtistParams, LidarrSerdeable}; use crate::network::lidarr_network::LidarrEvent; use crate::network::network_tests::test_utils::{MockServarrApi, test_network}; use pretty_assertions::assert_eq; @@ -41,4 +41,29 @@ mod tests { assert_eq!(artists, response); assert!(!app.lock().await.data.lidarr_data.artists.is_empty()); } + + #[tokio::test] + async fn test_handle_delete_artist_event() { + let delete_artist_params = DeleteArtistParams { + id: 1, + delete_files: true, + add_import_list_exclusion: true, + }; + let (async_server, app, _server) = MockServarrApi::delete() + .path("/1") + .query("deleteFiles=true&addImportListExclusion=true") + .build_for(LidarrEvent::DeleteArtist(delete_artist_params.clone())) + .await; + app.lock().await.server_tabs.set_index(2); + let mut network = test_network(&app); + + assert!( + network + .handle_lidarr_event(LidarrEvent::DeleteArtist(delete_artist_params)) + .await + .is_ok() + ); + + async_server.assert_async().await; + } } diff --git a/src/network/lidarr_network/library/mod.rs b/src/network/lidarr_network/library/mod.rs index 77b8a80..a2c0923 100644 --- a/src/network/lidarr_network/library/mod.rs +++ b/src/network/lidarr_network/library/mod.rs @@ -1,7 +1,7 @@ use anyhow::Result; use log::info; -use crate::models::lidarr_models::Artist; +use crate::models::lidarr_models::{Artist, DeleteArtistParams}; use crate::models::servarr_data::lidarr::lidarr_data::ActiveLidarrBlock; use crate::models::Route; use crate::network::lidarr_network::LidarrEvent; @@ -12,6 +12,38 @@ use crate::network::{Network, RequestMethod}; mod lidarr_library_network_tests; impl Network<'_, '_> { + pub(in crate::network::lidarr_network) async fn delete_artist( + &mut self, + delete_artist_params: DeleteArtistParams, + ) -> Result<()> { + let event = LidarrEvent::DeleteArtist(DeleteArtistParams::default()); + let DeleteArtistParams { + id, + delete_files, + add_import_list_exclusion, + } = delete_artist_params; + + info!( + "Deleting Lidarr artist with ID: {id} with deleteFiles={delete_files} and addImportListExclusion={add_import_list_exclusion}" + ); + + let request_props = self + .request_props_from( + event, + RequestMethod::Delete, + None::<()>, + Some(format!("/{id}")), + Some(format!( + "deleteFiles={delete_files}&addImportListExclusion={add_import_list_exclusion}" + )), + ) + .await; + + self + .handle_request::<(), ()>(request_props, |_, _| ()) + .await + } + pub(in crate::network::lidarr_network) async fn list_artists(&mut self) -> Result> { info!("Fetching Lidarr artists"); let event = LidarrEvent::ListArtists; diff --git a/src/network/lidarr_network/lidarr_network_tests.rs b/src/network/lidarr_network/lidarr_network_tests.rs index 53371ac..a435b34 100644 --- a/src/network/lidarr_network/lidarr_network_tests.rs +++ b/src/network/lidarr_network/lidarr_network_tests.rs @@ -1,8 +1,12 @@ #[cfg(test)] mod tests { - use crate::network::{NetworkEvent, NetworkResource, lidarr_network::LidarrEvent}; - use pretty_assertions::assert_str_eq; + use crate::models::lidarr_models::{LidarrSerdeable, MetadataProfile}; + use crate::models::servarr_models::{QualityProfile, Tag}; + use crate::network::network_tests::test_utils::{MockServarrApi, test_network}; + use crate::network::{lidarr_network::LidarrEvent, NetworkEvent, NetworkResource}; + use pretty_assertions::{assert_eq, assert_str_eq}; use rstest::rstest; + use serde_json::json; #[rstest] #[case(LidarrEvent::GetDiskSpace, "/diskspace")] @@ -25,4 +29,109 @@ mod tests { NetworkEvent::from(LidarrEvent::HealthCheck) ); } + + #[tokio::test] + async fn test_handle_get_metadata_profiles_event() { + let metadata_profiles_json = json!([{ + "id": 1, + "name": "Standard" + }]); + let response: Vec = + serde_json::from_value(metadata_profiles_json.clone()).unwrap(); + let (mock, app, _server) = MockServarrApi::get() + .returns(metadata_profiles_json) + .build_for(LidarrEvent::GetMetadataProfiles) + .await; + app.lock().await.server_tabs.set_index(2); + let mut network = test_network(&app); + + let result = network + .handle_lidarr_event(LidarrEvent::GetMetadataProfiles) + .await; + + mock.assert_async().await; + + let LidarrSerdeable::MetadataProfiles(metadata_profiles) = result.unwrap() else { + panic!("Expected MetadataProfiles"); + }; + + assert_eq!(metadata_profiles, response); + assert_eq!( + app + .lock() + .await + .data + .lidarr_data + .metadata_profile_map + .get_by_left(&1), + Some(&"Standard".to_owned()) + ); + } + + #[tokio::test] + async fn test_handle_get_quality_profiles_event() { + let quality_profiles_json = json!([{ + "id": 1, + "name": "Lossless" + }]); + let response: Vec = + serde_json::from_value(quality_profiles_json.clone()).unwrap(); + let (mock, app, _server) = MockServarrApi::get() + .returns(quality_profiles_json) + .build_for(LidarrEvent::GetQualityProfiles) + .await; + app.lock().await.server_tabs.set_index(2); + let mut network = test_network(&app); + + let result = network + .handle_lidarr_event(LidarrEvent::GetQualityProfiles) + .await; + + mock.assert_async().await; + + let LidarrSerdeable::QualityProfiles(quality_profiles) = result.unwrap() else { + panic!("Expected QualityProfiles"); + }; + + assert_eq!(quality_profiles, response); + assert_eq!( + app + .lock() + .await + .data + .lidarr_data + .quality_profile_map + .get_by_left(&1), + Some(&"Lossless".to_owned()) + ); + } + + #[tokio::test] + async fn test_handle_get_tags_event() { + let tags_json = json!([{ + "id": 1, + "label": "usenet" + }]); + let response: Vec = serde_json::from_value(tags_json.clone()).unwrap(); + let (mock, app, _server) = MockServarrApi::get() + .returns(tags_json) + .build_for(LidarrEvent::GetTags) + .await; + app.lock().await.server_tabs.set_index(2); + let mut network = test_network(&app); + + let result = network.handle_lidarr_event(LidarrEvent::GetTags).await; + + mock.assert_async().await; + + let LidarrSerdeable::Tags(tags) = result.unwrap() else { + panic!("Expected Tags"); + }; + + assert_eq!(tags, response); + assert_eq!( + app.lock().await.data.lidarr_data.tags_map.get_by_left(&1), + Some(&"usenet".to_owned()) + ); + } } diff --git a/src/network/lidarr_network/mod.rs b/src/network/lidarr_network/mod.rs index 86981d7..abb5ec2 100644 --- a/src/network/lidarr_network/mod.rs +++ b/src/network/lidarr_network/mod.rs @@ -1,10 +1,14 @@ use anyhow::Result; +use log::info; use super::{NetworkEvent, NetworkResource}; -use crate::models::lidarr_models::LidarrSerdeable; -use crate::network::Network; +use crate::models::lidarr_models::{DeleteArtistParams, LidarrSerdeable, MetadataProfile}; +use crate::models::servarr_models::{QualityProfile, Tag}; +use crate::network::{Network, RequestMethod}; +mod downloads; mod library; +mod root_folders; mod system; #[cfg(test)] @@ -13,6 +17,7 @@ mod lidarr_network_tests; #[derive(Debug, Eq, PartialEq, Clone)] pub enum LidarrEvent { + DeleteArtist(DeleteArtistParams), GetDiskSpace, GetDownloads(u64), GetMetadataProfiles, @@ -27,6 +32,7 @@ pub enum LidarrEvent { impl NetworkResource for LidarrEvent { fn resource(&self) -> &'static str { match &self { + LidarrEvent::DeleteArtist(_) | LidarrEvent::ListArtists => "/artist", LidarrEvent::GetDiskSpace => "/diskspace", LidarrEvent::GetDownloads(_) => "/queue", LidarrEvent::GetMetadataProfiles => "/metadataprofile", @@ -35,7 +41,6 @@ impl NetworkResource for LidarrEvent { LidarrEvent::GetStatus => "/system/status", LidarrEvent::GetTags => "/tag", LidarrEvent::HealthCheck => "/health", - LidarrEvent::ListArtists => "/artist", } } } @@ -52,6 +57,9 @@ impl Network<'_, '_> { lidarr_event: LidarrEvent, ) -> Result { match lidarr_event { + LidarrEvent::DeleteArtist(params) => { + self.delete_artist(params).await.map(LidarrSerdeable::from) + } LidarrEvent::GetDiskSpace => self .get_lidarr_diskspace() .await @@ -84,4 +92,58 @@ impl Network<'_, '_> { LidarrEvent::ListArtists => self.list_artists().await.map(LidarrSerdeable::from), } } + + async fn get_lidarr_metadata_profiles(&mut self) -> Result> { + info!("Fetching Lidarr metadata profiles"); + let event = LidarrEvent::GetMetadataProfiles; + + let request_props = self + .request_props_from(event, RequestMethod::Get, None::<()>, None, None) + .await; + + self + .handle_request::<(), Vec>(request_props, |metadata_profiles, mut app| { + app.data.lidarr_data.metadata_profile_map = metadata_profiles + .into_iter() + .map(|profile| (profile.id, profile.name)) + .collect(); + }) + .await + } + + async fn get_lidarr_quality_profiles(&mut self) -> Result> { + info!("Fetching Lidarr quality profiles"); + let event = LidarrEvent::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.lidarr_data.quality_profile_map = quality_profiles + .into_iter() + .map(|profile| (profile.id, profile.name)) + .collect(); + }) + .await + } + + async fn get_lidarr_tags(&mut self) -> Result> { + info!("Fetching Lidarr tags"); + let event = LidarrEvent::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.lidarr_data.tags_map = tags_vec + .into_iter() + .map(|tag| (tag.id, tag.label)) + .collect(); + }) + .await + } } diff --git a/src/network/lidarr_network/root_folders/lidarr_root_folders_network_tests.rs b/src/network/lidarr_network/root_folders/lidarr_root_folders_network_tests.rs new file mode 100644 index 0000000..f8bfaf5 --- /dev/null +++ b/src/network/lidarr_network/root_folders/lidarr_root_folders_network_tests.rs @@ -0,0 +1,39 @@ +#[cfg(test)] +mod tests { + use crate::models::lidarr_models::LidarrSerdeable; + use crate::models::servarr_models::RootFolder; + use crate::network::lidarr_network::LidarrEvent; + use crate::network::network_tests::test_utils::{MockServarrApi, test_network}; + use pretty_assertions::assert_eq; + use serde_json::json; + + #[tokio::test] + async fn test_handle_get_root_folders_event() { + let root_folders_json = json!([{ + "id": 1, + "path": "/music", + "accessible": true, + "freeSpace": 50000000000i64 + }]); + let response: Vec = serde_json::from_value(root_folders_json.clone()).unwrap(); + let (mock, app, _server) = MockServarrApi::get() + .returns(root_folders_json) + .build_for(LidarrEvent::GetRootFolders) + .await; + app.lock().await.server_tabs.set_index(2); + let mut network = test_network(&app); + + let result = network + .handle_lidarr_event(LidarrEvent::GetRootFolders) + .await; + + mock.assert_async().await; + + let LidarrSerdeable::RootFolders(root_folders) = result.unwrap() else { + panic!("Expected RootFolders"); + }; + + assert_eq!(root_folders, response); + assert!(!app.lock().await.data.lidarr_data.root_folders.is_empty()); + } +} diff --git a/src/network/lidarr_network/root_folders/mod.rs b/src/network/lidarr_network/root_folders/mod.rs new file mode 100644 index 0000000..274c24c --- /dev/null +++ b/src/network/lidarr_network/root_folders/mod.rs @@ -0,0 +1,29 @@ +use anyhow::Result; +use log::info; + +use crate::models::servarr_models::RootFolder; +use crate::network::lidarr_network::LidarrEvent; +use crate::network::{Network, RequestMethod}; + +#[cfg(test)] +#[path = "lidarr_root_folders_network_tests.rs"] +mod lidarr_root_folders_network_tests; + +impl Network<'_, '_> { + pub(in crate::network::lidarr_network) async fn get_lidarr_root_folders( + &mut self, + ) -> Result> { + info!("Fetching Lidarr root folders"); + let event = LidarrEvent::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.lidarr_data.root_folders.set_items(root_folders); + }) + .await + } +} diff --git a/src/network/lidarr_network/system/lidarr_system_network_tests.rs b/src/network/lidarr_network/system/lidarr_system_network_tests.rs index 4695fc9..1457437 100644 --- a/src/network/lidarr_network/system/lidarr_system_network_tests.rs +++ b/src/network/lidarr_network/system/lidarr_system_network_tests.rs @@ -1,9 +1,7 @@ #[cfg(test)] mod tests { - use crate::models::lidarr_models::{ - DownloadsResponse, LidarrSerdeable, MetadataProfile, SystemStatus, - }; - use crate::models::servarr_models::{DiskSpace, QualityProfile, RootFolder, Tag}; + use crate::models::lidarr_models::{LidarrSerdeable, SystemStatus}; + use crate::models::servarr_models::DiskSpace; use crate::network::lidarr_network::LidarrEvent; use crate::network::network_tests::test_utils::{MockServarrApi, test_network}; use pretty_assertions::assert_eq; @@ -22,111 +20,6 @@ mod tests { mock.assert_async().await; } - #[tokio::test] - async fn test_handle_get_metadata_profiles_event() { - let metadata_profiles_json = json!([{ - "id": 1, - "name": "Standard" - }]); - let response: Vec = - serde_json::from_value(metadata_profiles_json.clone()).unwrap(); - let (mock, app, _server) = MockServarrApi::get() - .returns(metadata_profiles_json) - .build_for(LidarrEvent::GetMetadataProfiles) - .await; - app.lock().await.server_tabs.set_index(2); - let mut network = test_network(&app); - - let result = network - .handle_lidarr_event(LidarrEvent::GetMetadataProfiles) - .await; - - mock.assert_async().await; - - let LidarrSerdeable::MetadataProfiles(metadata_profiles) = result.unwrap() else { - panic!("Expected MetadataProfiles"); - }; - - assert_eq!(metadata_profiles, response); - assert_eq!( - app - .lock() - .await - .data - .lidarr_data - .metadata_profile_map - .get_by_left(&1), - Some(&"Standard".to_owned()) - ); - } - - #[tokio::test] - async fn test_handle_get_quality_profiles_event() { - let quality_profiles_json = json!([{ - "id": 1, - "name": "Lossless" - }]); - let response: Vec = - serde_json::from_value(quality_profiles_json.clone()).unwrap(); - let (mock, app, _server) = MockServarrApi::get() - .returns(quality_profiles_json) - .build_for(LidarrEvent::GetQualityProfiles) - .await; - app.lock().await.server_tabs.set_index(2); - let mut network = test_network(&app); - - let result = network - .handle_lidarr_event(LidarrEvent::GetQualityProfiles) - .await; - - mock.assert_async().await; - - let LidarrSerdeable::QualityProfiles(quality_profiles) = result.unwrap() else { - panic!("Expected QualityProfiles"); - }; - - assert_eq!(quality_profiles, response); - assert_eq!( - app - .lock() - .await - .data - .lidarr_data - .quality_profile_map - .get_by_left(&1), - Some(&"Lossless".to_owned()) - ); - } - - #[tokio::test] - async fn test_handle_get_tags_event() { - let tags_json = json!([{ - "id": 1, - "label": "usenet" - }]); - let response: Vec = serde_json::from_value(tags_json.clone()).unwrap(); - let (mock, app, _server) = MockServarrApi::get() - .returns(tags_json) - .build_for(LidarrEvent::GetTags) - .await; - app.lock().await.server_tabs.set_index(2); - let mut network = test_network(&app); - - let result = network.handle_lidarr_event(LidarrEvent::GetTags).await; - - mock.assert_async().await; - - let LidarrSerdeable::Tags(tags) = result.unwrap() else { - panic!("Expected Tags"); - }; - - assert_eq!(tags, response); - assert_eq!( - app.lock().await.data.lidarr_data.tags_map.get_by_left(&1), - Some(&"usenet".to_owned()) - ); - } - #[tokio::test] async fn test_handle_get_diskspace_event() { let diskspace_json = json!([{ @@ -153,71 +46,6 @@ mod tests { assert!(!app.lock().await.data.lidarr_data.disk_space_vec.is_empty()); } - #[tokio::test] - async fn test_handle_get_downloads_event() { - let downloads_json = json!({ - "records": [{ - "title": "Test Album", - "status": "downloading", - "id": 1, - "size": 100.0, - "sizeleft": 50.0, - "indexer": "test-indexer" - }] - }); - let response: DownloadsResponse = serde_json::from_value(downloads_json.clone()).unwrap(); - let (mock, app, _server) = MockServarrApi::get() - .returns(downloads_json) - .query("pageSize=500") - .build_for(LidarrEvent::GetDownloads(500)) - .await; - app.lock().await.server_tabs.set_index(2); - let mut network = test_network(&app); - - let result = network - .handle_lidarr_event(LidarrEvent::GetDownloads(500)) - .await; - - mock.assert_async().await; - - let LidarrSerdeable::DownloadsResponse(downloads_response) = result.unwrap() else { - panic!("Expected DownloadsResponse"); - }; - - assert_eq!(downloads_response, response); - assert!(!app.lock().await.data.lidarr_data.downloads.is_empty()); - } - - #[tokio::test] - async fn test_handle_get_root_folders_event() { - let root_folders_json = json!([{ - "id": 1, - "path": "/music", - "accessible": true, - "freeSpace": 50000000000i64 - }]); - let response: Vec = serde_json::from_value(root_folders_json.clone()).unwrap(); - let (mock, app, _server) = MockServarrApi::get() - .returns(root_folders_json) - .build_for(LidarrEvent::GetRootFolders) - .await; - app.lock().await.server_tabs.set_index(2); - let mut network = test_network(&app); - - let result = network - .handle_lidarr_event(LidarrEvent::GetRootFolders) - .await; - - mock.assert_async().await; - - let LidarrSerdeable::RootFolders(root_folders) = result.unwrap() else { - panic!("Expected RootFolders"); - }; - - assert_eq!(root_folders, response); - assert!(!app.lock().await.data.lidarr_data.root_folders.is_empty()); - } - #[tokio::test] async fn test_handle_get_status_event() { let status_json = json!({ diff --git a/src/network/lidarr_network/system/mod.rs b/src/network/lidarr_network/system/mod.rs index 8cbedf2..2cb9196 100644 --- a/src/network/lidarr_network/system/mod.rs +++ b/src/network/lidarr_network/system/mod.rs @@ -1,8 +1,8 @@ use anyhow::Result; use log::info; -use crate::models::lidarr_models::{DownloadsResponse, MetadataProfile, SystemStatus}; -use crate::models::servarr_models::{DiskSpace, QualityProfile, RootFolder, Tag}; +use crate::models::lidarr_models::SystemStatus; +use crate::models::servarr_models::DiskSpace; use crate::network::lidarr_network::LidarrEvent; use crate::network::{Network, RequestMethod}; @@ -24,64 +24,6 @@ impl Network<'_, '_> { .await } - pub(in crate::network::lidarr_network) async fn get_lidarr_metadata_profiles( - &mut self, - ) -> Result> { - info!("Fetching Lidarr metadata profiles"); - let event = LidarrEvent::GetMetadataProfiles; - - let request_props = self - .request_props_from(event, RequestMethod::Get, None::<()>, None, None) - .await; - - self - .handle_request::<(), Vec>(request_props, |metadata_profiles, mut app| { - app.data.lidarr_data.metadata_profile_map = metadata_profiles - .into_iter() - .map(|profile| (profile.id, profile.name)) - .collect(); - }) - .await - } - - pub(in crate::network::lidarr_network) async fn get_lidarr_quality_profiles( - &mut self, - ) -> Result> { - info!("Fetching Lidarr quality profiles"); - let event = LidarrEvent::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.lidarr_data.quality_profile_map = quality_profiles - .into_iter() - .map(|profile| (profile.id, profile.name)) - .collect(); - }) - .await - } - - pub(in crate::network::lidarr_network) async fn get_lidarr_tags(&mut self) -> Result> { - info!("Fetching Lidarr tags"); - let event = LidarrEvent::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.lidarr_data.tags_map = tags_vec - .into_iter() - .map(|tag| (tag.id, tag.label)) - .collect(); - }) - .await - } - pub(in crate::network::lidarr_network) async fn get_lidarr_diskspace( &mut self, ) -> Result> { @@ -99,51 +41,6 @@ impl Network<'_, '_> { .await } - pub(in crate::network::lidarr_network) async fn get_lidarr_downloads( - &mut self, - count: u64, - ) -> Result { - info!("Fetching Lidarr downloads"); - let event = LidarrEvent::GetDownloads(count); - - let request_props = self - .request_props_from( - event, - RequestMethod::Get, - None::<()>, - None, - Some(format!("pageSize={count}")), - ) - .await; - - self - .handle_request::<(), DownloadsResponse>(request_props, |queue_response, mut app| { - app - .data - .lidarr_data - .downloads - .set_items(queue_response.records); - }) - .await - } - - pub(in crate::network::lidarr_network) async fn get_lidarr_root_folders( - &mut self, - ) -> Result> { - info!("Fetching Lidarr root folders"); - let event = LidarrEvent::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.lidarr_data.root_folders.set_items(root_folders); - }) - .await - } - pub(in crate::network::lidarr_network) async fn get_lidarr_status( &mut self, ) -> Result { diff --git a/src/ui/lidarr_ui/library/delete_artist_ui.rs b/src/ui/lidarr_ui/library/delete_artist_ui.rs new file mode 100644 index 0000000..f6333f2 --- /dev/null +++ b/src/ui/lidarr_ui/library/delete_artist_ui.rs @@ -0,0 +1,57 @@ +use ratatui::Frame; +use ratatui::layout::Rect; + +use crate::app::App; +use crate::models::Route; +use crate::models::servarr_data::lidarr::lidarr_data::{ActiveLidarrBlock, DELETE_ARTIST_BLOCKS}; +use crate::ui::DrawUi; +use crate::ui::widgets::checkbox::Checkbox; +use crate::ui::widgets::confirmation_prompt::ConfirmationPrompt; +use crate::ui::widgets::popup::{Popup, Size}; + +#[cfg(test)] +#[path = "delete_artist_ui_tests.rs"] +mod delete_artist_ui_tests; + +pub(in crate::ui::lidarr_ui) struct DeleteArtistUi; + +impl DrawUi for DeleteArtistUi { + fn accepts(route: Route) -> bool { + let Route::Lidarr(active_lidarr_block, _) = route else { + return false; + }; + DELETE_ARTIST_BLOCKS.contains(&active_lidarr_block) + } + + fn draw(f: &mut Frame<'_>, app: &mut App<'_>, _area: Rect) { + if matches!( + app.get_current_route(), + Route::Lidarr(ActiveLidarrBlock::DeleteArtistPrompt, _) + ) { + let selected_block = app.data.lidarr_data.selected_block.get_active_block(); + let prompt = format!( + "Do you really want to delete the artist: \n{}?", + app.data.lidarr_data.artists.current_selection().artist_name.text + ); + let checkboxes = vec![ + Checkbox::new("Delete Artist Files") + .checked(app.data.lidarr_data.delete_artist_files) + .highlighted(selected_block == ActiveLidarrBlock::DeleteArtistToggleDeleteFile), + Checkbox::new("Add List Exclusion") + .checked(app.data.lidarr_data.add_import_list_exclusion) + .highlighted(selected_block == ActiveLidarrBlock::DeleteArtistToggleAddListExclusion), + ]; + let confirmation_prompt = ConfirmationPrompt::new() + .title("Delete Artist") + .prompt(&prompt) + .checkboxes(checkboxes) + .yes_no_highlighted(selected_block == ActiveLidarrBlock::DeleteArtistConfirmPrompt) + .yes_no_value(app.data.lidarr_data.prompt_confirm); + + f.render_widget( + Popup::new(confirmation_prompt).size(Size::MediumPrompt), + f.area(), + ); + } + } +} diff --git a/src/ui/lidarr_ui/library/delete_artist_ui_tests.rs b/src/ui/lidarr_ui/library/delete_artist_ui_tests.rs new file mode 100644 index 0000000..9ccb8ba --- /dev/null +++ b/src/ui/lidarr_ui/library/delete_artist_ui_tests.rs @@ -0,0 +1,44 @@ +#[cfg(test)] +mod tests { + use strum::IntoEnumIterator; + + use crate::app::App; + use crate::models::BlockSelectionState; + use crate::models::servarr_data::lidarr::lidarr_data::{ + ActiveLidarrBlock, DELETE_ARTIST_BLOCKS, DELETE_ARTIST_SELECTION_BLOCKS, + }; + use crate::ui::DrawUi; + use crate::ui::lidarr_ui::library::delete_artist_ui::DeleteArtistUi; + use crate::ui::ui_test_utils::test_utils::render_to_string_with_app; + + #[test] + fn test_delete_artist_ui_accepts() { + ActiveLidarrBlock::iter().for_each(|active_lidarr_block| { + if DELETE_ARTIST_BLOCKS.contains(&active_lidarr_block) { + assert!(DeleteArtistUi::accepts(active_lidarr_block.into())); + } else { + assert!(!DeleteArtistUi::accepts(active_lidarr_block.into())); + } + }); + } + + mod snapshot_tests { + use crate::ui::ui_test_utils::test_utils::TerminalSize; + + use super::*; + + #[test] + fn test_delete_artist_ui_renders_delete_artist() { + let mut app = App::test_default_fully_populated(); + app.push_navigation_stack(ActiveLidarrBlock::DeleteArtistPrompt.into()); + app.data.lidarr_data.selected_block = + BlockSelectionState::new(DELETE_ARTIST_SELECTION_BLOCKS); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + DeleteArtistUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(output); + } + } +} diff --git a/src/ui/lidarr_ui/library/library_ui_tests.rs b/src/ui/lidarr_ui/library/library_ui_tests.rs index 92bf3ce..9f7e931 100644 --- a/src/ui/lidarr_ui/library/library_ui_tests.rs +++ b/src/ui/lidarr_ui/library/library_ui_tests.rs @@ -2,19 +2,250 @@ mod tests { use strum::IntoEnumIterator; - use crate::models::servarr_data::lidarr::lidarr_data::{ActiveLidarrBlock, LIBRARY_BLOCKS}; - use crate::models::Route; + use crate::models::lidarr_models::{Artist, ArtistStatistics, ArtistStatus}; + use crate::models::servarr_data::lidarr::lidarr_data::{ + ActiveLidarrBlock, DELETE_ARTIST_BLOCKS, LIBRARY_BLOCKS, + }; + use crate::ui::lidarr_ui::library::{LibraryUi, decorate_artist_row_with_style}; + use crate::ui::styles::ManagarrStyle; use crate::ui::DrawUi; - use crate::ui::lidarr_ui::library::LibraryUi; + use pretty_assertions::assert_eq; + use ratatui::widgets::{Cell, Row}; #[test] fn test_library_ui_accepts() { - for lidarr_block in ActiveLidarrBlock::iter() { - if LIBRARY_BLOCKS.contains(&lidarr_block) { - assert!(LibraryUi::accepts(Route::Lidarr(lidarr_block, None))); + let mut library_ui_blocks = Vec::new(); + library_ui_blocks.extend(LIBRARY_BLOCKS); + library_ui_blocks.extend(DELETE_ARTIST_BLOCKS); + for active_lidarr_block in ActiveLidarrBlock::iter() { + if library_ui_blocks.contains(&active_lidarr_block) { + assert!(LibraryUi::accepts(active_lidarr_block.into())); } else { - assert!(!LibraryUi::accepts(Route::Lidarr(lidarr_block, None))); + assert!(!LibraryUi::accepts(active_lidarr_block.into())); } } } + + #[test] + fn test_decorate_row_with_style_unmonitored() { + let artist = Artist::default(); + let row = Row::new(vec![Cell::from("test".to_owned())]); + + let style = decorate_artist_row_with_style(&artist, row.clone()); + + assert_eq!(style, row.unmonitored()); + } + + #[test] + fn test_decorate_row_with_style_downloaded_when_ended_and_all_tracks_present() { + let artist = Artist { + monitored: true, + status: ArtistStatus::Ended, + statistics: Some(ArtistStatistics { + track_file_count: 10, + total_track_count: 10, + ..ArtistStatistics::default() + }), + ..Artist::default() + }; + let row = Row::new(vec![Cell::from("test".to_owned())]); + + let style = decorate_artist_row_with_style(&artist, row.clone()); + + assert_eq!(style, row.downloaded()); + } + + #[test] + fn test_decorate_row_with_style_missing_when_ended_and_tracks_are_missing() { + let artist = Artist { + monitored: true, + status: ArtistStatus::Ended, + statistics: Some(ArtistStatistics { + track_file_count: 5, + total_track_count: 10, + ..ArtistStatistics::default() + }), + ..Artist::default() + }; + let row = Row::new(vec![Cell::from("test".to_owned())]); + + let style = decorate_artist_row_with_style(&artist, row.clone()); + + assert_eq!(style, row.missing()); + } + + #[test] + fn test_decorate_row_with_style_indeterminate_when_ended_and_no_statistics() { + let artist = Artist { + monitored: true, + status: ArtistStatus::Ended, + statistics: None, + ..Artist::default() + }; + let row = Row::new(vec![Cell::from("test".to_owned())]); + + let style = decorate_artist_row_with_style(&artist, row.clone()); + + assert_eq!(style, row.indeterminate()); + } + + #[test] + fn test_decorate_row_with_style_indeterminate_when_ended_and_total_track_count_is_zero() { + let artist = Artist { + monitored: true, + status: ArtistStatus::Ended, + statistics: Some(ArtistStatistics { + track_file_count: 0, + total_track_count: 0, + ..ArtistStatistics::default() + }), + ..Artist::default() + }; + let row = Row::new(vec![Cell::from("test".to_owned())]); + + let style = decorate_artist_row_with_style(&artist, row.clone()); + + assert_eq!(style, row.missing()); + } + + #[test] + fn test_decorate_row_with_style_unreleased_when_continuing_and_all_tracks_present() { + let artist = Artist { + monitored: true, + status: ArtistStatus::Continuing, + statistics: Some(ArtistStatistics { + track_file_count: 10, + total_track_count: 10, + ..ArtistStatistics::default() + }), + ..Artist::default() + }; + let row = Row::new(vec![Cell::from("test".to_owned())]); + + let style = decorate_artist_row_with_style(&artist, row.clone()); + + assert_eq!(style, row.unreleased()); + } + + #[test] + fn test_decorate_row_with_style_missing_when_continuing_and_tracks_are_missing() { + let artist = Artist { + monitored: true, + status: ArtistStatus::Continuing, + statistics: Some(ArtistStatistics { + track_file_count: 5, + total_track_count: 10, + ..ArtistStatistics::default() + }), + ..Artist::default() + }; + let row = Row::new(vec![Cell::from("test".to_owned())]); + + let style = decorate_artist_row_with_style(&artist, row.clone()); + + assert_eq!(style, row.missing()); + } + + #[test] + fn test_decorate_row_with_style_indeterminate_when_continuing_and_no_statistics() { + let artist = Artist { + monitored: true, + status: ArtistStatus::Continuing, + statistics: None, + ..Artist::default() + }; + let row = Row::new(vec![Cell::from("test".to_owned())]); + + let style = decorate_artist_row_with_style(&artist, row.clone()); + + assert_eq!(style, row.indeterminate()); + } + + #[test] + fn test_decorate_row_with_style_defaults_to_indeterminate_for_deleted_status() { + let artist = Artist { + monitored: true, + status: ArtistStatus::Deleted, + ..Artist::default() + }; + let row = Row::new(vec![Cell::from("test".to_owned())]); + + let style = decorate_artist_row_with_style(&artist, row.clone()); + + assert_eq!(style, row.indeterminate()); + } + + mod snapshot_tests { + use crate::app::App; + use crate::models::BlockSelectionState; + use crate::models::servarr_data::lidarr::lidarr_data::{ + ActiveLidarrBlock, DELETE_ARTIST_SELECTION_BLOCKS, + }; + use rstest::rstest; + + use crate::ui::lidarr_ui::library::LibraryUi; + use crate::ui::ui_test_utils::test_utils::{TerminalSize, render_to_string_with_app}; + use crate::ui::DrawUi; + + #[rstest] + fn test_library_ui_renders( + #[values( + ActiveLidarrBlock::Artists, + ActiveLidarrBlock::ArtistsSortPrompt, + ActiveLidarrBlock::SearchArtists, + ActiveLidarrBlock::SearchArtistsError, + ActiveLidarrBlock::FilterArtists, + ActiveLidarrBlock::FilterArtistsError + )] + active_lidarr_block: ActiveLidarrBlock, + ) { + let mut app = App::test_default_fully_populated(); + app.push_navigation_stack(active_lidarr_block.into()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + LibraryUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(format!("lidarr_library_{active_lidarr_block}"), output); + } + + #[test] + fn test_library_ui_renders_loading() { + let mut app = App::test_default_fully_populated(); + app.is_loading = true; + app.push_navigation_stack(ActiveLidarrBlock::Artists.into()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + LibraryUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(output); + } + + #[test] + fn test_library_ui_renders_empty() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveLidarrBlock::Artists.into()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + LibraryUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(output); + } + + #[test] + fn test_library_ui_renders_delete_artist_over_library() { + let mut app = App::test_default_fully_populated(); + app.push_navigation_stack(ActiveLidarrBlock::DeleteArtistPrompt.into()); + app.data.lidarr_data.selected_block = + BlockSelectionState::new(DELETE_ARTIST_SELECTION_BLOCKS); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + LibraryUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(output); + } + } } diff --git a/src/ui/lidarr_ui/library/mod.rs b/src/ui/lidarr_ui/library/mod.rs index a54c803..88e8a76 100644 --- a/src/ui/lidarr_ui/library/mod.rs +++ b/src/ui/lidarr_ui/library/mod.rs @@ -1,3 +1,4 @@ +use delete_artist_ui::DeleteArtistUi; use ratatui::{ Frame, layout::{Constraint, Rect}, @@ -20,6 +21,8 @@ use crate::{ }, }; +mod delete_artist_ui; + #[cfg(test)] #[path = "library_ui_tests.rs"] mod library_ui_tests; @@ -29,14 +32,19 @@ pub(super) struct LibraryUi; impl DrawUi for LibraryUi { fn accepts(route: Route) -> bool { if let Route::Lidarr(active_lidarr_block, _) = route { - return LIBRARY_BLOCKS.contains(&active_lidarr_block); + return DeleteArtistUi::accepts(route) || LIBRARY_BLOCKS.contains(&active_lidarr_block); } false } fn draw(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { + let route = app.get_current_route(); draw_library(f, app, area); + + if DeleteArtistUi::accepts(route) { + DeleteArtistUi::draw(f, app, area); + } } } diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__delete_artist_ui__delete_artist_ui_tests__tests__snapshot_tests__delete_artist_ui_renders_delete_artist.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__delete_artist_ui__delete_artist_ui_tests__tests__snapshot_tests__delete_artist_ui_renders_delete_artist.snap new file mode 100644 index 0000000..6329eb8 --- /dev/null +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__delete_artist_ui__delete_artist_ui_tests__tests__snapshot_tests__delete_artist_ui_renders_delete_artist.snap @@ -0,0 +1,38 @@ +--- +source: src/ui/lidarr_ui/library/delete_artist_ui_tests.rs +expression: output +--- + + + + + + + + + + + + + + + + + ╭───────────────────── Delete Artist ─────────────────────╮ + │ Do you really want to delete the artist: │ + │ ? │ + │ │ + │ │ + │ ╭───╮ │ + │ Delete Artist Files: │ │ │ + │ ╰───╯ │ + │ ╭───╮ │ + │ Add List Exclusion: │ │ │ + │ ╰───╯ │ + │ │ + │ │ + │ │ + │╭────────────────────────────╮╭───────────────────────────╮│ + ││ Yes ││ No ││ + │╰────────────────────────────╯╰───────────────────────────╯│ + ╰───────────────────────────────────────────────────────────╯ diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_delete_artist_over_library.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_delete_artist_over_library.snap new file mode 100644 index 0000000..83323e6 --- /dev/null +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_delete_artist_over_library.snap @@ -0,0 +1,38 @@ +--- +source: src/ui/lidarr_ui/library/library_ui_tests.rs +expression: output +--- +───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + Name ▼ Type Status Quality Profile Metadata Profile Albums Tracks Size Monitored Tags +=> Continuing 0 0.00 GB + + + + + + + + + + + + + + ╭───────────────────── Delete Artist ─────────────────────╮ + │ Do you really want to delete the artist: │ + │ ? │ + │ │ + │ │ + │ ╭───╮ │ + │ Delete Artist Files: │ │ │ + │ ╰───╯ │ + │ ╭───╮ │ + │ Add List Exclusion: │ │ │ + │ ╰───╯ │ + │ │ + │ │ + │ │ + │╭────────────────────────────╮╭───────────────────────────╮│ + ││ Yes ││ No ││ + │╰────────────────────────────╯╰───────────────────────────╯│ + ╰───────────────────────────────────────────────────────────╯ diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_empty.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_empty.snap new file mode 100644 index 0000000..e17e3c0 --- /dev/null +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_empty.snap @@ -0,0 +1,5 @@ +--- +source: src/ui/lidarr_ui/library/library_ui_tests.rs +expression: output +--- +───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_loading.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_loading.snap new file mode 100644 index 0000000..b697d89 --- /dev/null +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_loading.snap @@ -0,0 +1,8 @@ +--- +source: src/ui/lidarr_ui/library/library_ui_tests.rs +expression: output +--- +───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + + + Loading ... diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__lidarr_library_Artists.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__lidarr_library_Artists.snap new file mode 100644 index 0000000..bf51331 --- /dev/null +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__lidarr_library_Artists.snap @@ -0,0 +1,7 @@ +--- +source: src/ui/lidarr_ui/library/library_ui_tests.rs +expression: output +--- +───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + Name ▼ Type Status Quality Profile Metadata Profile Albums Tracks Size Monitored Tags +=> Continuing 0 0.00 GB diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__lidarr_library_ArtistsSortPrompt.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__lidarr_library_ArtistsSortPrompt.snap new file mode 100644 index 0000000..1204b85 --- /dev/null +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__lidarr_library_ArtistsSortPrompt.snap @@ -0,0 +1,42 @@ +--- +source: src/ui/lidarr_ui/library/library_ui_tests.rs +expression: output +--- +───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + Name Type Status Quality Profile Metadata Profile Albums Tracks Size Monitored Tags +=> Continuing 0 0.00 GB + + + + + + + + + + + ╭───────────────────────────────╮ + │Name │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰───────────────────────────────╯ diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__lidarr_library_FilterArtists.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__lidarr_library_FilterArtists.snap new file mode 100644 index 0000000..3421078 --- /dev/null +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__lidarr_library_FilterArtists.snap @@ -0,0 +1,28 @@ +--- +source: src/ui/lidarr_ui/library/library_ui_tests.rs +expression: output +--- +───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + Name ▼ Type Status Quality Profile Metadata Profile Albums Tracks Size Monitored Tags +=> Continuing 0 0.00 GB + + + + + + + + + + + + + + + + + + + ╭───────────────── Filter ──────────────────╮ + │artist filter │ + ╰─────────────────────────────────────────────╯ diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__lidarr_library_FilterArtistsError.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__lidarr_library_FilterArtistsError.snap new file mode 100644 index 0000000..913cbea --- /dev/null +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__lidarr_library_FilterArtistsError.snap @@ -0,0 +1,31 @@ +--- +source: src/ui/lidarr_ui/library/library_ui_tests.rs +expression: output +--- +───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + Name ▼ Type Status Quality Profile Metadata Profile Albums Tracks Size Monitored Tags +=> Continuing 0 0.00 GB + + + + + + + + + + + + + + + + + + + + + ╭─────────────── Error ───────────────╮ + │The given filter produced empty results│ + │ │ + ╰───────────────────────────────────────╯ diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__lidarr_library_SearchArtists.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__lidarr_library_SearchArtists.snap new file mode 100644 index 0000000..42c7af9 --- /dev/null +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__lidarr_library_SearchArtists.snap @@ -0,0 +1,28 @@ +--- +source: src/ui/lidarr_ui/library/library_ui_tests.rs +expression: output +--- +───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + Name ▼ Type Status Quality Profile Metadata Profile Albums Tracks Size Monitored Tags +=> Continuing 0 0.00 GB + + + + + + + + + + + + + + + + + + + ╭───────────────── Search ──────────────────╮ + │artist search │ + ╰─────────────────────────────────────────────╯ diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__lidarr_library_SearchArtistsError.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__lidarr_library_SearchArtistsError.snap new file mode 100644 index 0000000..e9ee85e --- /dev/null +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__lidarr_library_SearchArtistsError.snap @@ -0,0 +1,31 @@ +--- +source: src/ui/lidarr_ui/library/library_ui_tests.rs +expression: output +--- +───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + Name ▼ Type Status Quality Profile Metadata Profile Albums Tracks Size Monitored Tags +=> Continuing 0 0.00 GB + + + + + + + + + + + + + + + + + + + + + ╭─────────────── Error ───────────────╮ + │ No items found matching search │ + │ │ + ╰───────────────────────────────────────╯ -- 2.52.0 From 059fa48bd9ca4ddcdc89407c7af7fadd858d954e Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Mon, 5 Jan 2026 15:46:16 -0700 Subject: [PATCH 05/61] style: Applied uniform formatting across all new Lidarr files --- src/app/lidarr/lidarr_context_clues_tests.rs | 8 ++--- src/app/lidarr/lidarr_tests.rs | 6 ++-- src/app/mod.rs | 4 +-- src/cli/cli_tests.rs | 8 ++--- .../lidarr/delete_command_handler_tests.rs | 3 +- src/cli/lidarr/lidarr_command_tests.rs | 11 +++--- src/handlers/handlers_tests.rs | 18 +++++----- .../library/library_handler_tests.rs | 2 +- src/handlers/lidarr_handlers/library/mod.rs | 19 +++++----- src/models/lidarr_models.rs | 11 +----- src/models/lidarr_models_tests.rs | 2 +- src/models/mod.rs | 4 +-- src/models/servarr_data/lidarr/lidarr_data.rs | 36 ++++++++++--------- .../servarr_data/lidarr/lidarr_data_tests.rs | 4 +-- src/models/servarr_data/mod.rs | 2 +- src/network/lidarr_network/library/mod.rs | 2 +- .../lidarr_network/lidarr_network_tests.rs | 2 +- src/network/lidarr_network/mod.rs | 10 ++---- src/network/mod.rs | 2 +- src/ui/lidarr_ui/library/delete_artist_ui.rs | 8 ++++- src/ui/lidarr_ui/library/library_ui_tests.rs | 4 +-- src/ui/lidarr_ui/library/mod.rs | 9 ++--- src/ui/lidarr_ui/lidarr_ui_tests.rs | 2 +- src/ui/lidarr_ui/mod.rs | 4 ++- src/ui/mod.rs | 2 +- src/utils.rs | 6 ++-- 26 files changed, 90 insertions(+), 99 deletions(-) diff --git a/src/app/lidarr/lidarr_context_clues_tests.rs b/src/app/lidarr/lidarr_context_clues_tests.rs index 75e4193..4e60644 100644 --- a/src/app/lidarr/lidarr_context_clues_tests.rs +++ b/src/app/lidarr/lidarr_context_clues_tests.rs @@ -1,12 +1,12 @@ #[cfg(test)] mod tests { + use crate::app::App; use crate::app::context_clues::ContextClueProvider; use crate::app::key_binding::DEFAULT_KEYBINDINGS; - use crate::app::lidarr::lidarr_context_clues::{LidarrContextClueProvider, ARTISTS_CONTEXT_CLUES}; - use crate::app::App; - use crate::models::servarr_data::lidarr::lidarr_data::{ - ActiveLidarrBlock, + use crate::app::lidarr::lidarr_context_clues::{ + ARTISTS_CONTEXT_CLUES, LidarrContextClueProvider, }; + use crate::models::servarr_data::lidarr::lidarr_data::ActiveLidarrBlock; use crate::models::servarr_data::radarr::radarr_data::ActiveRadarrBlock; #[test] diff --git a/src/app/lidarr/lidarr_tests.rs b/src/app/lidarr/lidarr_tests.rs index 60fe4c0..872cdd9 100644 --- a/src/app/lidarr/lidarr_tests.rs +++ b/src/app/lidarr/lidarr_tests.rs @@ -1,10 +1,10 @@ #[cfg(test)] mod tests { - use pretty_assertions::assert_eq; use crate::app::App; use crate::models::servarr_data::lidarr::lidarr_data::ActiveLidarrBlock; use crate::network::NetworkEvent; use crate::network::lidarr_network::LidarrEvent; + use pretty_assertions::assert_eq; use tokio::sync::mpsc; #[tokio::test] @@ -13,7 +13,9 @@ mod tests { let mut app = App::test_default(); app.network_tx = Some(tx); - app.dispatch_by_lidarr_block(&ActiveLidarrBlock::Artists).await; + app + .dispatch_by_lidarr_block(&ActiveLidarrBlock::Artists) + .await; assert!(app.is_loading); assert_eq!( diff --git a/src/app/mod.rs b/src/app/mod.rs index 7fcd297..7b4cd0c 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -26,9 +26,9 @@ mod app_tests; pub mod context_clues; pub mod key_binding; mod key_binding_tests; +pub mod lidarr; pub mod radarr; pub mod sonarr; -pub mod lidarr; pub struct App<'a> { navigation_stack: Vec, @@ -361,7 +361,7 @@ impl AppConfig { if let Some(sonarr_configs) = &self.sonarr { sonarr_configs.iter().for_each(|config| config.validate()); } - + if let Some(lidarr_configs) = &self.lidarr { lidarr_configs.iter().for_each(|config| config.validate()); } diff --git a/src/cli/cli_tests.rs b/src/cli/cli_tests.rs index 2beb319..6ec6f22 100644 --- a/src/cli/cli_tests.rs +++ b/src/cli/cli_tests.rs @@ -2,16 +2,18 @@ mod tests { use std::sync::Arc; - use clap::{error::ErrorKind, CommandFactory}; + use clap::{CommandFactory, error::ErrorKind}; use mockall::predicate::eq; use rstest::rstest; use serde_json::json; use tokio::sync::Mutex; use crate::{ + Cli, app::App, cli::{handle_command, mutex_flags_or_option, radarr::RadarrCommand, sonarr::SonarrCommand}, models::{ + Serdeable, radarr_models::{ BlocklistItem as RadarrBlocklistItem, BlocklistResponse as RadarrBlocklistResponse, RadarrSerdeable, @@ -20,12 +22,10 @@ mod tests { BlocklistItem as SonarrBlocklistItem, BlocklistResponse as SonarrBlocklistResponse, SonarrSerdeable, }, - Serdeable, }, network::{ - radarr_network::RadarrEvent, sonarr_network::SonarrEvent, MockNetworkTrait, NetworkEvent, + MockNetworkTrait, NetworkEvent, radarr_network::RadarrEvent, sonarr_network::SonarrEvent, }, - Cli, }; use pretty_assertions::assert_eq; diff --git a/src/cli/lidarr/delete_command_handler_tests.rs b/src/cli/lidarr/delete_command_handler_tests.rs index f36df29..dda5a61 100644 --- a/src/cli/lidarr/delete_command_handler_tests.rs +++ b/src/cli/lidarr/delete_command_handler_tests.rs @@ -29,8 +29,7 @@ mod tests { #[test] fn test_delete_artist_requires_arguments() { - let result = - Cli::command().try_get_matches_from(["managarr", "lidarr", "delete", "artist"]); + let result = Cli::command().try_get_matches_from(["managarr", "lidarr", "delete", "artist"]); assert_err!(&result); assert_eq!( diff --git a/src/cli/lidarr/lidarr_command_tests.rs b/src/cli/lidarr/lidarr_command_tests.rs index 653f6d4..0262b07 100644 --- a/src/cli/lidarr/lidarr_command_tests.rs +++ b/src/cli/lidarr/lidarr_command_tests.rs @@ -1,10 +1,10 @@ #[cfg(test)] mod tests { - use crate::cli::{ - lidarr::{list_command_handler::LidarrListCommand, LidarrCommand}, - Command, - }; use crate::Cli; + use crate::cli::{ + Command, + lidarr::{LidarrCommand, list_command_handler::LidarrListCommand}, + }; use clap::CommandFactory; use pretty_assertions::assert_eq; @@ -54,8 +54,7 @@ mod tests { cli::{ CliCommandHandler, lidarr::{ - LidarrCliHandler, LidarrCommand, - delete_command_handler::LidarrDeleteCommand, + LidarrCliHandler, LidarrCommand, delete_command_handler::LidarrDeleteCommand, list_command_handler::LidarrListCommand, }, }, diff --git a/src/handlers/handlers_tests.rs b/src/handlers/handlers_tests.rs index 48cc18d..819921b 100644 --- a/src/handlers/handlers_tests.rs +++ b/src/handlers/handlers_tests.rs @@ -9,23 +9,23 @@ mod tests { use rstest::rstest; use tokio_util::sync::CancellationToken; + use crate::app::App; use crate::app::context_clues::SERVARR_CONTEXT_CLUES; - use crate::app::key_binding::{KeyBinding, DEFAULT_KEYBINDINGS}; + use crate::app::key_binding::{DEFAULT_KEYBINDINGS, KeyBinding}; use crate::app::radarr::radarr_context_clues::{ LIBRARY_CONTEXT_CLUES, MOVIE_DETAILS_CONTEXT_CLUES, }; - use crate::app::App; use crate::event::Key; use crate::handlers::{handle_clear_errors, handle_prompt_toggle}; use crate::handlers::{handle_events, populate_keymapping_table}; + use crate::models::HorizontallyScrollableText; + use crate::models::Route; + use crate::models::servarr_data::ActiveKeybindingBlock; use crate::models::servarr_data::lidarr::lidarr_data::ActiveLidarrBlock; use crate::models::servarr_data::radarr::radarr_data::{ActiveRadarrBlock, RadarrData}; use crate::models::servarr_data::sonarr::sonarr_data::ActiveSonarrBlock; - use crate::models::servarr_data::ActiveKeybindingBlock; use crate::models::servarr_models::KeybindingItem; use crate::models::stateful_table::StatefulTable; - use crate::models::HorizontallyScrollableText; - use crate::models::Route; #[test] fn test_handle_clear_errors() { @@ -128,8 +128,8 @@ mod tests { } #[test] - fn test_handle_empties_keybindings_table_on_help_button_press_when_keybindings_table_is_already_populated( - ) { + fn test_handle_empties_keybindings_table_on_help_button_press_when_keybindings_table_is_already_populated() + { let mut app = App::test_default(); let keybinding_items = Vec::from(SERVARR_CONTEXT_CLUES) .iter() @@ -260,8 +260,8 @@ mod tests { } #[test] - fn test_populate_keymapping_table_populates_delegated_servarr_context_provider_options_before_global_options( - ) { + fn test_populate_keymapping_table_populates_delegated_servarr_context_provider_options_before_global_options() + { let mut expected_keybinding_items = MOVIE_DETAILS_CONTEXT_CLUES .iter() .map(|(key, desc)| context_clue_to_keybinding_item(key, desc)) diff --git a/src/handlers/lidarr_handlers/library/library_handler_tests.rs b/src/handlers/lidarr_handlers/library/library_handler_tests.rs index da5784b..e2e17d6 100644 --- a/src/handlers/lidarr_handlers/library/library_handler_tests.rs +++ b/src/handlers/lidarr_handlers/library/library_handler_tests.rs @@ -6,8 +6,8 @@ mod tests { use serde_json::Number; use strum::IntoEnumIterator; - use crate::handlers::lidarr_handlers::library::{LibraryHandler, artists_sorting_options}; use crate::handlers::KeyEventHandler; + use crate::handlers::lidarr_handlers::library::{LibraryHandler, artists_sorting_options}; use crate::models::lidarr_models::{Artist, ArtistStatistics, ArtistStatus}; use crate::models::servarr_data::lidarr::lidarr_data::{ActiveLidarrBlock, LIBRARY_BLOCKS}; diff --git a/src/handlers/lidarr_handlers/library/mod.rs b/src/handlers/lidarr_handlers/library/mod.rs index 2845c6d..ba82132 100644 --- a/src/handlers/lidarr_handlers/library/mod.rs +++ b/src/handlers/lidarr_handlers/library/mod.rs @@ -33,16 +33,15 @@ pub(super) struct LibraryHandler<'a, 'b> { impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveLidarrBlock> for LibraryHandler<'a, 'b> { fn handle(&mut self) { - let artists_table_handling_config = - TableHandlingConfig::new(ActiveLidarrBlock::Artists.into()) - .sorting_block(ActiveLidarrBlock::ArtistsSortPrompt.into()) - .sort_options(artists_sorting_options()) - .searching_block(ActiveLidarrBlock::SearchArtists.into()) - .search_error_block(ActiveLidarrBlock::SearchArtistsError.into()) - .search_field_fn(|artist| &artist.artist_name.text) - .filtering_block(ActiveLidarrBlock::FilterArtists.into()) - .filter_error_block(ActiveLidarrBlock::FilterArtistsError.into()) - .filter_field_fn(|artist| &artist.artist_name.text); + let artists_table_handling_config = TableHandlingConfig::new(ActiveLidarrBlock::Artists.into()) + .sorting_block(ActiveLidarrBlock::ArtistsSortPrompt.into()) + .sort_options(artists_sorting_options()) + .searching_block(ActiveLidarrBlock::SearchArtists.into()) + .search_error_block(ActiveLidarrBlock::SearchArtistsError.into()) + .search_field_fn(|artist| &artist.artist_name.text) + .filtering_block(ActiveLidarrBlock::FilterArtists.into()) + .filter_error_block(ActiveLidarrBlock::FilterArtistsError.into()) + .filter_field_fn(|artist| &artist.artist_name.text); if !handle_table( self, diff --git a/src/models/lidarr_models.rs b/src/models/lidarr_models.rs index 70ee24d..383bca8 100644 --- a/src/models/lidarr_models.rs +++ b/src/models/lidarr_models.rs @@ -37,16 +37,7 @@ pub struct Artist { } #[derive( - Serialize, - Deserialize, - Default, - PartialEq, - Eq, - Clone, - Copy, - Debug, - Display, - EnumDisplayStyle, + Serialize, Deserialize, Default, PartialEq, Eq, Clone, Copy, Debug, Display, EnumDisplayStyle, )] #[serde(rename_all = "camelCase")] #[strum(serialize_all = "camelCase")] diff --git a/src/models/lidarr_models_tests.rs b/src/models/lidarr_models_tests.rs index 70340f3..89b8996 100644 --- a/src/models/lidarr_models_tests.rs +++ b/src/models/lidarr_models_tests.rs @@ -4,8 +4,8 @@ mod tests { use serde_json::json; use crate::models::{ - lidarr_models::{Artist, ArtistStatistics, ArtistStatus, LidarrSerdeable, Ratings}, Serdeable, + lidarr_models::{Artist, ArtistStatistics, ArtistStatus, LidarrSerdeable, Ratings}, }; #[test] diff --git a/src/models/mod.rs b/src/models/mod.rs index 363ffb8..3eea7c3 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -1,14 +1,14 @@ use std::fmt::{Debug, Display, Formatter}; use std::sync::atomic::{AtomicUsize, Ordering}; -use crate::app::context_clues::ContextClue; use crate::app::ServarrConfig; +use crate::app::context_clues::ContextClue; use crate::models::servarr_data::lidarr::lidarr_data::ActiveLidarrBlock; use crate::models::servarr_data::radarr::radarr_data::ActiveRadarrBlock; use lidarr_models::LidarrSerdeable; use radarr_models::RadarrSerdeable; use regex::Regex; -use serde::{de, Deserialize, Deserializer, Serialize, Serializer}; +use serde::{Deserialize, Deserializer, Serialize, Serializer, de}; use serde_json::Number; use servarr_data::sonarr::sonarr_data::ActiveSonarrBlock; use sonarr_models::SonarrSerdeable; diff --git a/src/models/servarr_data/lidarr/lidarr_data.rs b/src/models/servarr_data/lidarr/lidarr_data.rs index 144c6d8..5d87459 100644 --- a/src/models/servarr_data/lidarr/lidarr_data.rs +++ b/src/models/servarr_data/lidarr/lidarr_data.rs @@ -1,8 +1,3 @@ -use bimap::BiMap; -use chrono::{DateTime, Utc}; -use strum::EnumIter; -#[cfg(test)] -use strum::{Display, EnumString}; use crate::app::lidarr::lidarr_context_clues::ARTISTS_CONTEXT_CLUES; use crate::models::{ Route, TabRoute, TabState, @@ -11,6 +6,11 @@ use crate::models::{ stateful_table::StatefulTable, }; use crate::network::lidarr_network::LidarrEvent; +use bimap::BiMap; +use chrono::{DateTime, Utc}; +use strum::EnumIter; +#[cfg(test)] +use strum::{Display, EnumString}; #[cfg(test)] #[path = "lidarr_data_tests.rs"] @@ -58,14 +58,12 @@ impl<'a> Default for LidarrData<'a> { start_time: Utc::now(), tags_map: BiMap::new(), version: String::new(), - main_tabs: TabState::new(vec![ - TabRoute { - title: "Library".to_string(), - route: ActiveLidarrBlock::Artists.into(), - contextual_help: Some(&ARTISTS_CONTEXT_CLUES), - config: None, - }, - ]), + main_tabs: TabState::new(vec![TabRoute { + title: "Library".to_string(), + route: ActiveLidarrBlock::Artists.into(), + contextual_help: Some(&ARTISTS_CONTEXT_CLUES), + config: None, + }]), } } } @@ -76,7 +74,7 @@ impl LidarrData<'_> { use crate::models::lidarr_models::{Artist, DownloadRecord}; use crate::models::servarr_models::{DiskSpace, RootFolder}; use crate::models::stateful_table::SortOption; - + let mut lidarr_data = LidarrData::default(); lidarr_data.artists.set_items(vec![Artist::default()]); lidarr_data.artists.sorting(vec![SortOption { @@ -92,10 +90,14 @@ impl LidarrData<'_> { free_space: 50000000000, total_space: 100000000000, }]; - lidarr_data.downloads.set_items(vec![DownloadRecord::default()]); - lidarr_data.root_folders.set_items(vec![RootFolder::default()]); + lidarr_data + .downloads + .set_items(vec![DownloadRecord::default()]); + lidarr_data + .root_folders + .set_items(vec![RootFolder::default()]); lidarr_data.version = "1.0.0".to_owned(); - + lidarr_data } } diff --git a/src/models/servarr_data/lidarr/lidarr_data_tests.rs b/src/models/servarr_data/lidarr/lidarr_data_tests.rs index d8cb0ef..0b67bbf 100644 --- a/src/models/servarr_data/lidarr/lidarr_data_tests.rs +++ b/src/models/servarr_data/lidarr/lidarr_data_tests.rs @@ -3,8 +3,8 @@ mod tests { use pretty_assertions::assert_eq; use crate::models::{ - servarr_data::lidarr::lidarr_data::{ActiveLidarrBlock, LidarrData}, Route, + servarr_data::lidarr::lidarr_data::{ActiveLidarrBlock, LidarrData}, }; #[test] @@ -25,7 +25,7 @@ mod tests { #[test] fn test_reset_delete_artist_preferences() { - let mut lidarr_data = LidarrData{ + let mut lidarr_data = LidarrData { delete_artist_files: true, add_import_list_exclusion: true, ..LidarrData::default() diff --git a/src/models/servarr_data/mod.rs b/src/models/servarr_data/mod.rs index bdf6f5b..256f0bc 100644 --- a/src/models/servarr_data/mod.rs +++ b/src/models/servarr_data/mod.rs @@ -1,9 +1,9 @@ use crate::models::Route; +pub mod lidarr; pub mod modals; pub mod radarr; pub mod sonarr; -pub mod lidarr; #[cfg(test)] pub(in crate::models::servarr_data) mod data_test_utils; diff --git a/src/network/lidarr_network/library/mod.rs b/src/network/lidarr_network/library/mod.rs index a2c0923..35d6f3c 100644 --- a/src/network/lidarr_network/library/mod.rs +++ b/src/network/lidarr_network/library/mod.rs @@ -1,9 +1,9 @@ use anyhow::Result; use log::info; +use crate::models::Route; use crate::models::lidarr_models::{Artist, DeleteArtistParams}; use crate::models::servarr_data::lidarr::lidarr_data::ActiveLidarrBlock; -use crate::models::Route; use crate::network::lidarr_network::LidarrEvent; use crate::network::{Network, RequestMethod}; diff --git a/src/network/lidarr_network/lidarr_network_tests.rs b/src/network/lidarr_network/lidarr_network_tests.rs index a435b34..fbf7c08 100644 --- a/src/network/lidarr_network/lidarr_network_tests.rs +++ b/src/network/lidarr_network/lidarr_network_tests.rs @@ -3,7 +3,7 @@ mod tests { use crate::models::lidarr_models::{LidarrSerdeable, MetadataProfile}; use crate::models::servarr_models::{QualityProfile, Tag}; use crate::network::network_tests::test_utils::{MockServarrApi, test_network}; - use crate::network::{lidarr_network::LidarrEvent, NetworkEvent, NetworkResource}; + use crate::network::{NetworkEvent, NetworkResource, lidarr_network::LidarrEvent}; use pretty_assertions::{assert_eq, assert_str_eq}; use rstest::rstest; use serde_json::json; diff --git a/src/network/lidarr_network/mod.rs b/src/network/lidarr_network/mod.rs index abb5ec2..ed782ab 100644 --- a/src/network/lidarr_network/mod.rs +++ b/src/network/lidarr_network/mod.rs @@ -60,10 +60,7 @@ impl Network<'_, '_> { LidarrEvent::DeleteArtist(params) => { self.delete_artist(params).await.map(LidarrSerdeable::from) } - LidarrEvent::GetDiskSpace => self - .get_lidarr_diskspace() - .await - .map(LidarrSerdeable::from), + LidarrEvent::GetDiskSpace => self.get_lidarr_diskspace().await.map(LidarrSerdeable::from), LidarrEvent::GetDownloads(count) => self .get_lidarr_downloads(count) .await @@ -80,10 +77,7 @@ impl Network<'_, '_> { .get_lidarr_root_folders() .await .map(LidarrSerdeable::from), - LidarrEvent::GetStatus => self - .get_lidarr_status() - .await - .map(LidarrSerdeable::from), + LidarrEvent::GetStatus => self.get_lidarr_status().await.map(LidarrSerdeable::from), LidarrEvent::GetTags => self.get_lidarr_tags().await.map(LidarrSerdeable::from), LidarrEvent::HealthCheck => self .get_lidarr_healthcheck() diff --git a/src/network/mod.rs b/src/network/mod.rs index 3a73a5a..dc8359f 100644 --- a/src/network/mod.rs +++ b/src/network/mod.rs @@ -3,12 +3,12 @@ use std::sync::Arc; use anyhow::{Result, anyhow}; use async_trait::async_trait; +use lidarr_network::LidarrEvent; use log::{debug, error, warn}; use regex::Regex; use reqwest::{Client, RequestBuilder}; use serde::Serialize; use serde::de::DeserializeOwned; -use lidarr_network::LidarrEvent; use sonarr_network::SonarrEvent; use strum_macros::Display; use tokio::select; diff --git a/src/ui/lidarr_ui/library/delete_artist_ui.rs b/src/ui/lidarr_ui/library/delete_artist_ui.rs index f6333f2..3dd690e 100644 --- a/src/ui/lidarr_ui/library/delete_artist_ui.rs +++ b/src/ui/lidarr_ui/library/delete_artist_ui.rs @@ -31,7 +31,13 @@ impl DrawUi for DeleteArtistUi { let selected_block = app.data.lidarr_data.selected_block.get_active_block(); let prompt = format!( "Do you really want to delete the artist: \n{}?", - app.data.lidarr_data.artists.current_selection().artist_name.text + app + .data + .lidarr_data + .artists + .current_selection() + .artist_name + .text ); let checkboxes = vec![ Checkbox::new("Delete Artist Files") diff --git a/src/ui/lidarr_ui/library/library_ui_tests.rs b/src/ui/lidarr_ui/library/library_ui_tests.rs index 9f7e931..56164aa 100644 --- a/src/ui/lidarr_ui/library/library_ui_tests.rs +++ b/src/ui/lidarr_ui/library/library_ui_tests.rs @@ -6,9 +6,9 @@ mod tests { use crate::models::servarr_data::lidarr::lidarr_data::{ ActiveLidarrBlock, DELETE_ARTIST_BLOCKS, LIBRARY_BLOCKS, }; + use crate::ui::DrawUi; use crate::ui::lidarr_ui::library::{LibraryUi, decorate_artist_row_with_style}; use crate::ui::styles::ManagarrStyle; - use crate::ui::DrawUi; use pretty_assertions::assert_eq; use ratatui::widgets::{Cell, Row}; @@ -183,9 +183,9 @@ mod tests { }; use rstest::rstest; + use crate::ui::DrawUi; use crate::ui::lidarr_ui::library::LibraryUi; use crate::ui::ui_test_utils::test_utils::{TerminalSize, render_to_string_with_app}; - use crate::ui::DrawUi; #[rstest] fn test_library_ui_renders( diff --git a/src/ui/lidarr_ui/library/mod.rs b/src/ui/lidarr_ui/library/mod.rs index 88e8a76..db3bb28 100644 --- a/src/ui/lidarr_ui/library/mod.rs +++ b/src/ui/lidarr_ui/library/mod.rs @@ -84,12 +84,9 @@ fn draw_library(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { .statistics .as_ref() .map_or(0, |stats| stats.album_count); - let tracks = artist - .statistics - .as_ref() - .map_or(String::new(), |stats| { - format!("{}/{}", stats.track_file_count, stats.total_track_count) - }); + let tracks = artist.statistics.as_ref().map_or(String::new(), |stats| { + format!("{}/{}", stats.track_file_count, stats.total_track_count) + }); let tags = artist .tags .iter() diff --git a/src/ui/lidarr_ui/lidarr_ui_tests.rs b/src/ui/lidarr_ui/lidarr_ui_tests.rs index d29e8d1..46fd44a 100644 --- a/src/ui/lidarr_ui/lidarr_ui_tests.rs +++ b/src/ui/lidarr_ui/lidarr_ui_tests.rs @@ -2,8 +2,8 @@ mod tests { use strum::IntoEnumIterator; - use crate::models::servarr_data::lidarr::lidarr_data::ActiveLidarrBlock; use crate::models::Route; + use crate::models::servarr_data::lidarr::lidarr_data::ActiveLidarrBlock; use crate::ui::DrawUi; use crate::ui::lidarr_ui::LidarrUi; diff --git a/src/ui/lidarr_ui/mod.rs b/src/ui/lidarr_ui/mod.rs index faec50f..a5df07c 100644 --- a/src/ui/lidarr_ui/mod.rs +++ b/src/ui/lidarr_ui/mod.rs @@ -29,7 +29,9 @@ use crate::{ use super::{ DrawUi, draw_tabs, styles::ManagarrStyle, - utils::{borderless_block, layout_block, line_gauge_with_label, line_gauge_with_title, title_block}, + utils::{ + borderless_block, layout_block, line_gauge_with_label, line_gauge_with_title, title_block, + }, widgets::loading_block::LoadingBlock, }; diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 71e6837..1bff146 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -1,6 +1,7 @@ use std::cell::Cell; use std::sync::atomic::Ordering; +use lidarr_ui::LidarrUi; use ratatui::Frame; use ratatui::layout::{Constraint, Flex, Layout, Rect}; use ratatui::style::{Style, Stylize}; @@ -9,7 +10,6 @@ use ratatui::widgets::Paragraph; use ratatui::widgets::Tabs; use ratatui::widgets::Wrap; use ratatui::widgets::{Clear, Row}; -use lidarr_ui::LidarrUi; use sonarr_ui::SonarrUi; use utils::layout_block; diff --git a/src/utils.rs b/src/utils.rs index b43e9b8..87e04db 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -6,10 +6,10 @@ use std::sync::Arc; use std::time::Duration; use anyhow::Result; -use anyhow::{anyhow, Context}; +use anyhow::{Context, anyhow}; use colored::Colorize; use indicatif::{ProgressBar, ProgressStyle}; -use log::{error, LevelFilter}; +use log::{LevelFilter, error}; use log4rs::append::file::FileAppender; use log4rs::config::{Appender, Root}; use log4rs::encode::pattern::PatternEncoder; @@ -18,7 +18,7 @@ use reqwest::{Certificate, Client}; use tokio::sync::Mutex; use tokio_util::sync::CancellationToken; -use crate::app::{log_and_print_error, App, AppConfig}; +use crate::app::{App, AppConfig, log_and_print_error}; use crate::cli::{self, Command}; use crate::network::Network; use crate::ui::theme::ThemeDefinitionsWrapper; -- 2.52.0 From 5afee1998b73b3effa94ba7f3b1bc82f70cd8505 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Tue, 6 Jan 2026 09:40:16 -0700 Subject: [PATCH 06/61] feat: Support for toggling the monitoring of a given artist via the CLI and TUI --- src/app/lidarr/lidarr_context_clues.rs | 6 +- src/app/lidarr/lidarr_context_clues_tests.rs | 7 + src/cli/lidarr/lidarr_command_tests.rs | 55 ++++++ src/cli/lidarr/mod.rs | 21 +- .../library/library_handler_tests.rs | 53 +++++ src/handlers/lidarr_handlers/library/mod.rs | 26 ++- src/models/lidarr_models.rs | 40 +++- src/models/lidarr_models_tests.rs | 185 ++++++++++++++++++ .../library/lidarr_library_network_tests.rs | 86 ++++++++ src/network/lidarr_network/library/mod.rs | 88 ++++++++- .../lidarr_network/lidarr_network_tests.rs | 13 +- src/network/lidarr_network/mod.rs | 15 +- 12 files changed, 583 insertions(+), 12 deletions(-) diff --git a/src/app/lidarr/lidarr_context_clues.rs b/src/app/lidarr/lidarr_context_clues.rs index 772cb27..cf0b3c9 100644 --- a/src/app/lidarr/lidarr_context_clues.rs +++ b/src/app/lidarr/lidarr_context_clues.rs @@ -7,7 +7,11 @@ use crate::models::Route; #[path = "lidarr_context_clues_tests.rs"] mod lidarr_context_clues_tests; -pub static ARTISTS_CONTEXT_CLUES: [ContextClue; 6] = [ +pub static ARTISTS_CONTEXT_CLUES: [ContextClue; 7] = [ + ( + DEFAULT_KEYBINDINGS.toggle_monitoring, + DEFAULT_KEYBINDINGS.toggle_monitoring.desc, + ), (DEFAULT_KEYBINDINGS.sort, DEFAULT_KEYBINDINGS.sort.desc), (DEFAULT_KEYBINDINGS.delete, DEFAULT_KEYBINDINGS.delete.desc), (DEFAULT_KEYBINDINGS.search, DEFAULT_KEYBINDINGS.search.desc), diff --git a/src/app/lidarr/lidarr_context_clues_tests.rs b/src/app/lidarr/lidarr_context_clues_tests.rs index 4e60644..542469a 100644 --- a/src/app/lidarr/lidarr_context_clues_tests.rs +++ b/src/app/lidarr/lidarr_context_clues_tests.rs @@ -13,6 +13,13 @@ mod tests { fn test_artists_context_clues() { let mut artists_context_clues_iter = ARTISTS_CONTEXT_CLUES.iter(); + assert_some_eq_x!( + artists_context_clues_iter.next(), + &( + DEFAULT_KEYBINDINGS.toggle_monitoring, + DEFAULT_KEYBINDINGS.toggle_monitoring.desc + ) + ); assert_some_eq_x!( artists_context_clues_iter.next(), &(DEFAULT_KEYBINDINGS.sort, DEFAULT_KEYBINDINGS.sort.desc) diff --git a/src/cli/lidarr/lidarr_command_tests.rs b/src/cli/lidarr/lidarr_command_tests.rs index 0262b07..dede7cc 100644 --- a/src/cli/lidarr/lidarr_command_tests.rs +++ b/src/cli/lidarr/lidarr_command_tests.rs @@ -19,6 +19,8 @@ mod tests { mod cli { use super::*; + use clap::error::ErrorKind; + use pretty_assertions::assert_eq; #[test] fn test_list_artists_has_no_arg_requirements() { @@ -40,6 +42,31 @@ mod tests { assert_err!(&result); } + + #[test] + fn test_toggle_artist_monitoring_requires_artist_id() { + let result = + Cli::command().try_get_matches_from(["managarr", "lidarr", "toggle-artist-monitoring"]); + + assert_err!(&result); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_toggle_artist_monitoring_requirements_satisfied() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "lidarr", + "toggle-artist-monitoring", + "--artist-id", + "1", + ]); + + assert_ok!(&result); + } } mod handler { @@ -119,5 +146,33 @@ mod tests { assert_ok!(&result); } + + #[tokio::test] + async fn test_toggle_artist_monitoring_command() { + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::( + LidarrEvent::ToggleArtistMonitoring(1).into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Lidarr(LidarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::test_default())); + let toggle_artist_monitoring_command = LidarrCommand::ToggleArtistMonitoring { artist_id: 1 }; + + let result = LidarrCliHandler::with( + &app_arc, + toggle_artist_monitoring_command, + &mut mock_network, + ) + .handle() + .await; + + assert_ok!(&result); + } } } diff --git a/src/cli/lidarr/mod.rs b/src/cli/lidarr/mod.rs index 1251bbb..43f2bed 100644 --- a/src/cli/lidarr/mod.rs +++ b/src/cli/lidarr/mod.rs @@ -1,11 +1,12 @@ use std::sync::Arc; use anyhow::Result; -use clap::Subcommand; +use clap::{Subcommand, arg}; use delete_command_handler::{LidarrDeleteCommand, LidarrDeleteCommandHandler}; use list_command_handler::{LidarrListCommand, LidarrListCommandHandler}; use tokio::sync::Mutex; +use crate::network::lidarr_network::LidarrEvent; use crate::{app::App, network::NetworkTrait}; use super::{CliCommandHandler, Command}; @@ -29,6 +30,17 @@ pub enum LidarrCommand { about = "Commands to list attributes from your Lidarr instance" )] List(LidarrListCommand), + #[command( + about = "Toggle monitoring for the specified artist corresponding to the given artist ID" + )] + ToggleArtistMonitoring { + #[arg( + long, + help = "The Lidarr ID of the artist to toggle monitoring on", + required = true + )] + artist_id: i64, + }, } impl From for Command { @@ -68,6 +80,13 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, LidarrCommand> for LidarrCliHandler<'a, ' .handle() .await? } + LidarrCommand::ToggleArtistMonitoring { artist_id } => { + let resp = self + .network + .handle_network_event(LidarrEvent::ToggleArtistMonitoring(artist_id).into()) + .await?; + serde_json::to_string_pretty(&resp)? + } }; Ok(result) diff --git a/src/handlers/lidarr_handlers/library/library_handler_tests.rs b/src/handlers/lidarr_handlers/library/library_handler_tests.rs index e2e17d6..f3bdc24 100644 --- a/src/handlers/lidarr_handlers/library/library_handler_tests.rs +++ b/src/handlers/lidarr_handlers/library/library_handler_tests.rs @@ -6,10 +6,14 @@ mod tests { use serde_json::Number; use strum::IntoEnumIterator; + use crate::app::App; + use crate::app::key_binding::DEFAULT_KEYBINDINGS; + use crate::assert_modal_absent; use crate::handlers::KeyEventHandler; use crate::handlers::lidarr_handlers::library::{LibraryHandler, artists_sorting_options}; use crate::models::lidarr_models::{Artist, ArtistStatistics, ArtistStatus}; use crate::models::servarr_data::lidarr::lidarr_data::{ActiveLidarrBlock, LIBRARY_BLOCKS}; + use crate::network::lidarr_network::LidarrEvent; #[test] fn test_library_handler_accepts() { @@ -214,6 +218,55 @@ mod tests { assert_str_eq!(sort_option.name, "Tags"); } + #[test] + fn test_toggle_monitoring_key() { + let mut app = App::test_default(); + app + .data + .lidarr_data + .artists + .set_items(vec![Artist::default()]); + app.push_navigation_stack(ActiveLidarrBlock::Artists.into()); + app.is_routing = false; + + LibraryHandler::new( + DEFAULT_KEYBINDINGS.toggle_monitoring.key, + &mut app, + ActiveLidarrBlock::Artists, + None, + ) + .handle(); + + assert_eq!(app.get_current_route(), ActiveLidarrBlock::Artists.into()); + assert!(app.data.lidarr_data.prompt_confirm); + assert!(app.is_routing); + assert_some_eq_x!( + &app.data.lidarr_data.prompt_confirm_action, + &LidarrEvent::ToggleArtistMonitoring(0) + ); + } + + #[test] + fn test_toggle_monitoring_key_no_op_when_not_ready() { + let mut app = App::test_default(); + app.is_loading = true; + app.push_navigation_stack(ActiveLidarrBlock::Artists.into()); + app.is_routing = false; + + LibraryHandler::new( + DEFAULT_KEYBINDINGS.toggle_monitoring.key, + &mut app, + ActiveLidarrBlock::Artists, + None, + ) + .handle(); + + assert_eq!(app.get_current_route(), ActiveLidarrBlock::Artists.into()); + assert!(!app.data.lidarr_data.prompt_confirm); + assert_modal_absent!(app.data.lidarr_data.prompt_confirm_action); + assert!(!app.is_routing); + } + fn artists_vec() -> Vec { vec![ Artist { diff --git a/src/handlers/lidarr_handlers/library/mod.rs b/src/handlers/lidarr_handlers/library/mod.rs index ba82132..82c87c0 100644 --- a/src/handlers/lidarr_handlers/library/mod.rs +++ b/src/handlers/lidarr_handlers/library/mod.rs @@ -11,6 +11,7 @@ use crate::{ }, stateful_table::SortOption, }, + network::lidarr_network::LidarrEvent, }; use super::handle_change_tab_left_right_keys; @@ -31,6 +32,12 @@ pub(super) struct LibraryHandler<'a, 'b> { _context: Option, } +impl LibraryHandler<'_, '_> { + fn extract_artist_id(&self) -> i64 { + self.app.data.lidarr_data.artists.current_selection().id + } +} + impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveLidarrBlock> for LibraryHandler<'a, 'b> { fn handle(&mut self) { let artists_table_handling_config = TableHandlingConfig::new(ActiveLidarrBlock::Artists.into()) @@ -114,8 +121,23 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveLidarrBlock> for LibraryHandler<'a, ' fn handle_char_key_event(&mut self) { let key = self.key; - if self.active_lidarr_block == ActiveLidarrBlock::Artists && matches_key!(refresh, key) { - self.app.should_refresh = true; + if self.active_lidarr_block == ActiveLidarrBlock::Artists { + match key { + _ if matches_key!(toggle_monitoring, key) => { + self.app.data.lidarr_data.prompt_confirm = true; + self.app.data.lidarr_data.prompt_confirm_action = Some( + LidarrEvent::ToggleArtistMonitoring(self.extract_artist_id()), + ); + + self + .app + .pop_and_push_navigation_stack(self.active_lidarr_block.into()); + } + _ if matches_key!(refresh, key) => { + self.app.should_refresh = true; + } + _ => (), + } } } diff --git a/src/models/lidarr_models.rs b/src/models/lidarr_models.rs index 383bca8..bdb8706 100644 --- a/src/models/lidarr_models.rs +++ b/src/models/lidarr_models.rs @@ -5,7 +5,10 @@ use serde::{Deserialize, Serialize}; use serde_json::{Number, Value}; use strum::{Display, EnumIter}; -use super::{HorizontallyScrollableText, Serdeable}; +use super::{ + HorizontallyScrollableText, Serdeable, + servarr_models::{DiskSpace, QualityProfile, RootFolder, Tag}, +}; use crate::serde_enum_from; #[cfg(test)] @@ -29,6 +32,7 @@ pub struct Artist { #[serde(deserialize_with = "super::from_i64")] pub metadata_profile_id: i64, pub monitored: bool, + pub monitor_new_items: NewItemMonitorType, pub genres: Vec, pub tags: Vec, pub added: DateTime, @@ -94,6 +98,31 @@ impl From<(&i64, &String)> for MetadataProfile { } } +#[derive( + Serialize, + Deserialize, + Default, + PartialEq, + Eq, + Clone, + Copy, + Debug, + EnumIter, + Display, + EnumDisplayStyle, +)] +#[serde(rename_all = "camelCase")] +#[strum(serialize_all = "camelCase")] +pub enum NewItemMonitorType { + #[default] + #[display_style(name = "All Albums")] + All, + #[display_style(name = "No New Albums")] + None, + #[display_style(name = "New Albums")] + New, +} + #[derive(Derivative, Serialize, Deserialize, Debug, Default, Clone, PartialEq)] #[serde(rename_all = "camelCase")] pub struct DownloadRecord { @@ -174,14 +203,15 @@ impl From for Serdeable { serde_enum_from!( LidarrSerdeable { + Artist(Artist), Artists(Vec), - DiskSpaces(Vec), + DiskSpaces(Vec), DownloadsResponse(DownloadsResponse), MetadataProfiles(Vec), - QualityProfiles(Vec), - RootFolders(Vec), + QualityProfiles(Vec), + RootFolders(Vec), SystemStatus(SystemStatus), - Tags(Vec), + Tags(Vec), Value(Value), } ); diff --git a/src/models/lidarr_models_tests.rs b/src/models/lidarr_models_tests.rs index 89b8996..e8f7ea3 100644 --- a/src/models/lidarr_models_tests.rs +++ b/src/models/lidarr_models_tests.rs @@ -1,8 +1,14 @@ #[cfg(test)] mod tests { + use chrono::Utc; use pretty_assertions::{assert_eq, assert_str_eq}; use serde_json::json; + use crate::models::lidarr_models::{ + DownloadRecord, DownloadStatus, DownloadsResponse, MetadataProfile, NewItemMonitorType, + SystemStatus, + }; + use crate::models::servarr_models::{DiskSpace, QualityProfile, RootFolder, Tag}; use crate::models::{ Serdeable, lidarr_models::{Artist, ArtistStatistics, ArtistStatus, LidarrSerdeable, Ratings}, @@ -13,6 +19,20 @@ mod tests { assert_eq!(ArtistStatus::default(), ArtistStatus::Continuing); } + #[test] + fn test_new_item_monitor_type_display() { + assert_str_eq!(NewItemMonitorType::All.to_string(), "all"); + assert_str_eq!(NewItemMonitorType::None.to_string(), "none"); + assert_str_eq!(NewItemMonitorType::New.to_string(), "new"); + } + + #[test] + fn test_new_item_monitor_type_to_display_str() { + assert_str_eq!(NewItemMonitorType::All.to_display_str(), "All Albums"); + assert_str_eq!(NewItemMonitorType::None.to_display_str(), "No New Albums"); + assert_str_eq!(NewItemMonitorType::New.to_display_str(), "New Albums"); + } + #[test] fn test_lidarr_serdeable_from() { let lidarr_serdeable = LidarrSerdeable::Value(json!({})); @@ -65,6 +85,7 @@ mod tests { "qualityProfileId": 1, "metadataProfileId": 1, "monitored": true, + "monitorNewItems": "all", "genres": ["Rock", "Alternative"], "tags": [1, 2], "added": "2023-01-01T00:00:00Z", @@ -95,6 +116,7 @@ mod tests { assert_eq!(artist.quality_profile_id, 1); assert_eq!(artist.metadata_profile_id, 1); assert!(artist.monitored); + assert_eq!(artist.monitor_new_items, NewItemMonitorType::All); assert_eq!(artist.genres, vec!["Rock", "Alternative"]); assert_eq!(artist.tags.len(), 2); assert_some!(&artist.ratings); @@ -184,6 +206,7 @@ mod tests { "qualityProfileId": 1, "metadataProfileId": 1, "monitored": false, + "monitorNewItems": "all", "genres": [], "tags": [], "added": "2023-01-01T00:00:00Z" @@ -194,7 +217,169 @@ mod tests { assert_none!(&artist.overview); assert_none!(&artist.artist_type); assert_none!(&artist.disambiguation); + assert_eq!(artist.monitor_new_items, NewItemMonitorType::All); assert_none!(&artist.ratings); assert_none!(&artist.statistics); } + + #[test] + fn test_lidarr_serdeable_from_artist() { + let artist = Artist { + id: 1, + ..Artist::default() + }; + + let lidarr_serdeable: LidarrSerdeable = artist.clone().into(); + + assert_eq!(lidarr_serdeable, LidarrSerdeable::Artist(artist)); + } + + #[test] + fn test_lidarr_serdeable_from_disk_spaces() { + let disk_spaces = vec![DiskSpace { + free_space: 1, + total_space: 1, + }]; + + let lidarr_serdeable: LidarrSerdeable = disk_spaces.clone().into(); + + assert_eq!(lidarr_serdeable, LidarrSerdeable::DiskSpaces(disk_spaces)); + } + + #[test] + fn test_lidarr_serdeable_from_downloads_response() { + let downloads_response = DownloadsResponse { + records: vec![DownloadRecord { + id: 1, + ..DownloadRecord::default() + }], + }; + + let lidarr_serdeable: LidarrSerdeable = downloads_response.clone().into(); + + assert_eq!( + lidarr_serdeable, + LidarrSerdeable::DownloadsResponse(downloads_response) + ); + } + + #[test] + fn test_lidarr_serdeable_from_metadata_profiles() { + let metadata_profiles = vec![MetadataProfile { + id: 1, + name: "Standard".to_owned(), + }]; + + let lidarr_serdeable: LidarrSerdeable = metadata_profiles.clone().into(); + + assert_eq!( + lidarr_serdeable, + LidarrSerdeable::MetadataProfiles(metadata_profiles) + ); + } + + #[test] + fn test_lidarr_serdeable_from_quality_profiles() { + let quality_profiles = vec![QualityProfile { + id: 1, + name: "Any".to_owned(), + }]; + + let lidarr_serdeable: LidarrSerdeable = quality_profiles.clone().into(); + + assert_eq!( + lidarr_serdeable, + LidarrSerdeable::QualityProfiles(quality_profiles) + ); + } + + #[test] + fn test_lidarr_serdeable_from_root_folders() { + let root_folders = vec![RootFolder { + id: 1, + path: "/music".to_owned(), + accessible: true, + free_space: 1000000, + unmapped_folders: None, + }]; + + let lidarr_serdeable: LidarrSerdeable = root_folders.clone().into(); + + assert_eq!(lidarr_serdeable, LidarrSerdeable::RootFolders(root_folders)); + } + + #[test] + fn test_lidarr_serdeable_from_system_status() { + let system_status = SystemStatus { + version: "1.0.0".to_owned(), + start_time: Utc::now(), + }; + + let lidarr_serdeable: LidarrSerdeable = system_status.clone().into(); + + assert_eq!( + lidarr_serdeable, + LidarrSerdeable::SystemStatus(system_status) + ); + } + + #[test] + fn test_lidarr_serdeable_from_tags() { + let tags = vec![Tag { + id: 1, + label: "rock".to_owned(), + }]; + + let lidarr_serdeable: LidarrSerdeable = tags.clone().into(); + + assert_eq!(lidarr_serdeable, LidarrSerdeable::Tags(tags)); + } + + #[test] + fn test_artist_status_display() { + assert_str_eq!(ArtistStatus::Continuing.to_string(), "continuing"); + assert_str_eq!(ArtistStatus::Ended.to_string(), "ended"); + assert_str_eq!(ArtistStatus::Deleted.to_string(), "deleted"); + } + + #[test] + fn test_artist_status_to_display_str() { + assert_str_eq!(ArtistStatus::Continuing.to_display_str(), "Continuing"); + assert_str_eq!(ArtistStatus::Ended.to_display_str(), "Ended"); + assert_str_eq!(ArtistStatus::Deleted.to_display_str(), "Deleted"); + } + + #[test] + fn test_download_status_display() { + assert_str_eq!(DownloadStatus::Unknown.to_string(), "unknown"); + assert_str_eq!(DownloadStatus::Queued.to_string(), "queued"); + assert_str_eq!(DownloadStatus::Paused.to_string(), "paused"); + assert_str_eq!(DownloadStatus::Downloading.to_string(), "downloading"); + assert_str_eq!(DownloadStatus::Completed.to_string(), "completed"); + assert_str_eq!(DownloadStatus::Failed.to_string(), "failed"); + assert_str_eq!(DownloadStatus::Warning.to_string(), "warning"); + assert_str_eq!(DownloadStatus::Delay.to_string(), "delay"); + assert_str_eq!( + DownloadStatus::DownloadClientUnavailable.to_string(), + "downloadClientUnavailable" + ); + assert_str_eq!(DownloadStatus::Fallback.to_string(), "fallback"); + } + + #[test] + fn test_download_status_to_display_str() { + assert_str_eq!(DownloadStatus::Unknown.to_display_str(), "Unknown"); + assert_str_eq!(DownloadStatus::Queued.to_display_str(), "Queued"); + assert_str_eq!(DownloadStatus::Paused.to_display_str(), "Paused"); + assert_str_eq!(DownloadStatus::Downloading.to_display_str(), "Downloading"); + assert_str_eq!(DownloadStatus::Completed.to_display_str(), "Completed"); + assert_str_eq!(DownloadStatus::Failed.to_display_str(), "Failed"); + assert_str_eq!(DownloadStatus::Warning.to_display_str(), "Warning"); + assert_str_eq!(DownloadStatus::Delay.to_display_str(), "Delay"); + assert_str_eq!( + DownloadStatus::DownloadClientUnavailable.to_display_str(), + "Download Client Unavailable" + ); + assert_str_eq!(DownloadStatus::Fallback.to_display_str(), "Fallback"); + } } diff --git a/src/network/lidarr_network/library/lidarr_library_network_tests.rs b/src/network/lidarr_network/library/lidarr_library_network_tests.rs index 9d6bc36..4944a8c 100644 --- a/src/network/lidarr_network/library/lidarr_library_network_tests.rs +++ b/src/network/lidarr_network/library/lidarr_library_network_tests.rs @@ -3,6 +3,7 @@ mod tests { use crate::models::lidarr_models::{Artist, DeleteArtistParams, LidarrSerdeable}; use crate::network::lidarr_network::LidarrEvent; use crate::network::network_tests::test_utils::{MockServarrApi, test_network}; + use mockito::Matcher; use pretty_assertions::assert_eq; use serde_json::json; @@ -18,6 +19,7 @@ mod tests { "qualityProfileId": 1, "metadataProfileId": 1, "monitored": true, + "monitorNewItems": "all", "genres": [], "tags": [], "added": "2023-01-01T00:00:00Z" @@ -66,4 +68,88 @@ mod tests { async_server.assert_async().await; } + + #[tokio::test] + async fn test_handle_get_artist_details_event() { + let artist_json = json!({ + "id": 1, + "mbId": "test-mb-id", + "artistName": "Test Artist", + "foreignArtistId": "test-foreign-id", + "status": "continuing", + "path": "/music/test-artist", + "qualityProfileId": 1, + "metadataProfileId": 1, + "monitored": true, + "monitorNewItems": "all", + "genres": [], + "tags": [], + "added": "2023-01-01T00:00:00Z" + }); + let response: Artist = serde_json::from_value(artist_json.clone()).unwrap(); + let (mock, app, _server) = MockServarrApi::get() + .returns(artist_json) + .path("/1") + .build_for(LidarrEvent::GetArtistDetails(1)) + .await; + app.lock().await.server_tabs.set_index(2); + let mut network = test_network(&app); + + let result = network + .handle_lidarr_event(LidarrEvent::GetArtistDetails(1)) + .await; + + mock.assert_async().await; + + let LidarrSerdeable::Artist(artist) = result.unwrap() else { + panic!("Expected Artist"); + }; + + assert_eq!(artist, response); + } + + #[tokio::test] + async fn test_handle_toggle_artist_monitoring_event() { + let artist_json = json!({ + "id": 1, + "mbId": "test-mb-id", + "artistName": "Test Artist", + "foreignArtistId": "test-foreign-id", + "status": "continuing", + "path": "/music/test-artist", + "qualityProfileId": 1, + "metadataProfileId": 1, + "monitored": true, + "monitorNewItems": "all", + "genres": [], + "tags": [], + "added": "2023-01-01T00:00:00Z" + }); + let mut expected_body = artist_json.clone(); + *expected_body.get_mut("monitored").unwrap() = json!(false); + let (get_mock, app, mut server) = MockServarrApi::get() + .returns(artist_json) + .path("/1") + .build_for(LidarrEvent::GetArtistDetails(1)) + .await; + let put_mock = server + .mock("PUT", "/api/v1/artist/1") + .match_body(Matcher::Json(expected_body)) + .match_header("X-Api-Key", "test1234") + .with_status(202) + .create_async() + .await; + app.lock().await.server_tabs.set_index(2); + let mut network = test_network(&app); + + assert!( + network + .handle_lidarr_event(LidarrEvent::ToggleArtistMonitoring(1)) + .await + .is_ok() + ); + + get_mock.assert_async().await; + put_mock.assert_async().await; + } } diff --git a/src/network/lidarr_network/library/mod.rs b/src/network/lidarr_network/library/mod.rs index 35d6f3c..98987c6 100644 --- a/src/network/lidarr_network/library/mod.rs +++ b/src/network/lidarr_network/library/mod.rs @@ -1,5 +1,6 @@ use anyhow::Result; -use log::info; +use log::{debug, info, warn}; +use serde_json::{Value, json}; use crate::models::Route; use crate::models::lidarr_models::{Artist, DeleteArtistParams}; @@ -65,4 +66,89 @@ impl Network<'_, '_> { }) .await } + + pub(in crate::network::lidarr_network) async fn get_artist_details( + &mut self, + artist_id: i64, + ) -> Result { + info!("Fetching details for Lidarr artist with ID: {artist_id}"); + let event = LidarrEvent::GetArtistDetails(artist_id); + + let request_props = self + .request_props_from( + event, + RequestMethod::Get, + None::<()>, + Some(format!("/{artist_id}")), + None, + ) + .await; + + self + .handle_request::<(), Artist>(request_props, |_, _| ()) + .await + } + + pub(in crate::network::lidarr_network) async fn toggle_artist_monitoring( + &mut self, + artist_id: i64, + ) -> Result<()> { + let event = LidarrEvent::ToggleArtistMonitoring(artist_id); + + let detail_event = LidarrEvent::GetArtistDetails(artist_id); + info!("Toggling artist monitoring for artist with ID: {artist_id}"); + info!("Fetching artist details for artist with ID: {artist_id}"); + + let request_props = self + .request_props_from( + detail_event, + RequestMethod::Get, + None::<()>, + Some(format!("/{artist_id}")), + None, + ) + .await; + + let mut response = String::new(); + + self + .handle_request::<(), Value>(request_props, |detailed_artist_body, _| { + response = detailed_artist_body.to_string() + }) + .await?; + + info!("Constructing toggle artist monitoring body"); + + match serde_json::from_str::(&response) { + Ok(mut detailed_artist_body) => { + let monitored = detailed_artist_body + .get("monitored") + .unwrap() + .as_bool() + .unwrap(); + + *detailed_artist_body.get_mut("monitored").unwrap() = json!(!monitored); + + debug!("Toggle artist monitoring body: {detailed_artist_body:?}"); + + let request_props = self + .request_props_from( + event, + RequestMethod::Put, + Some(detailed_artist_body), + Some(format!("/{artist_id}")), + None, + ) + .await; + + self + .handle_request::(request_props, |_, _| ()) + .await + } + Err(_) => { + warn!("Request for detailed artist body was interrupted"); + Ok(()) + } + } + } } diff --git a/src/network/lidarr_network/lidarr_network_tests.rs b/src/network/lidarr_network/lidarr_network_tests.rs index fbf7c08..f97f4e5 100644 --- a/src/network/lidarr_network/lidarr_network_tests.rs +++ b/src/network/lidarr_network/lidarr_network_tests.rs @@ -8,6 +8,18 @@ mod tests { use rstest::rstest; use serde_json::json; + #[rstest] + fn test_resource_artist( + #[values( + LidarrEvent::GetArtistDetails(0), + LidarrEvent::ListArtists, + LidarrEvent::ToggleArtistMonitoring(0) + )] + event: LidarrEvent, + ) { + assert_str_eq!(event.resource(), "/artist"); + } + #[rstest] #[case(LidarrEvent::GetDiskSpace, "/diskspace")] #[case(LidarrEvent::GetDownloads(500), "/queue")] @@ -17,7 +29,6 @@ mod tests { #[case(LidarrEvent::GetStatus, "/system/status")] #[case(LidarrEvent::GetTags, "/tag")] #[case(LidarrEvent::HealthCheck, "/health")] - #[case(LidarrEvent::ListArtists, "/artist")] fn test_resource(#[case] event: LidarrEvent, #[case] expected_uri: &str) { assert_str_eq!(event.resource(), expected_uri); } diff --git a/src/network/lidarr_network/mod.rs b/src/network/lidarr_network/mod.rs index ed782ab..db62bef 100644 --- a/src/network/lidarr_network/mod.rs +++ b/src/network/lidarr_network/mod.rs @@ -18,6 +18,7 @@ mod lidarr_network_tests; #[derive(Debug, Eq, PartialEq, Clone)] pub enum LidarrEvent { DeleteArtist(DeleteArtistParams), + GetArtistDetails(i64), GetDiskSpace, GetDownloads(u64), GetMetadataProfiles, @@ -27,12 +28,16 @@ pub enum LidarrEvent { GetTags, HealthCheck, ListArtists, + ToggleArtistMonitoring(i64), } impl NetworkResource for LidarrEvent { fn resource(&self) -> &'static str { match &self { - LidarrEvent::DeleteArtist(_) | LidarrEvent::ListArtists => "/artist", + LidarrEvent::DeleteArtist(_) + | LidarrEvent::GetArtistDetails(_) + | LidarrEvent::ListArtists + | LidarrEvent::ToggleArtistMonitoring(_) => "/artist", LidarrEvent::GetDiskSpace => "/diskspace", LidarrEvent::GetDownloads(_) => "/queue", LidarrEvent::GetMetadataProfiles => "/metadataprofile", @@ -60,6 +65,10 @@ impl Network<'_, '_> { LidarrEvent::DeleteArtist(params) => { self.delete_artist(params).await.map(LidarrSerdeable::from) } + LidarrEvent::GetArtistDetails(artist_id) => self + .get_artist_details(artist_id) + .await + .map(LidarrSerdeable::from), LidarrEvent::GetDiskSpace => self.get_lidarr_diskspace().await.map(LidarrSerdeable::from), LidarrEvent::GetDownloads(count) => self .get_lidarr_downloads(count) @@ -84,6 +93,10 @@ impl Network<'_, '_> { .await .map(LidarrSerdeable::from), LidarrEvent::ListArtists => self.list_artists().await.map(LidarrSerdeable::from), + LidarrEvent::ToggleArtistMonitoring(artist_id) => self + .toggle_artist_monitoring(artist_id) + .await + .map(LidarrSerdeable::from), } } -- 2.52.0 From a012f6ecd5aa329236e4b767ecaed5aa252ea1d7 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Tue, 6 Jan 2026 10:10:28 -0700 Subject: [PATCH 07/61] feat: Fetch the artist members as part of the artist details query --- src/models/lidarr_models.rs | 10 +++++++++ src/models/lidarr_models_tests.rs | 19 +++++++++++++--- .../library/lidarr_library_network_tests.rs | 22 ++++++++++++++----- 3 files changed, 42 insertions(+), 9 deletions(-) diff --git a/src/models/lidarr_models.rs b/src/models/lidarr_models.rs index bdb8706..0cc6884 100644 --- a/src/models/lidarr_models.rs +++ b/src/models/lidarr_models.rs @@ -26,6 +26,7 @@ pub struct Artist { pub overview: Option, pub artist_type: Option, pub disambiguation: Option, + pub members: Option>, pub path: String, #[serde(deserialize_with = "super::from_i64")] pub quality_profile_id: i64, @@ -63,6 +64,15 @@ pub struct Ratings { impl Eq for Ratings {} +#[derive(Derivative, Serialize, Deserialize, Debug, Default, Clone, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct Member { + pub name: Option, + pub instrument: Option, +} + +impl Eq for Member {} + #[derive(Derivative, Serialize, Deserialize, Debug, Default, Clone, PartialEq)] #[serde(rename_all = "camelCase")] pub struct ArtistStatistics { diff --git a/src/models/lidarr_models_tests.rs b/src/models/lidarr_models_tests.rs index e8f7ea3..a280538 100644 --- a/src/models/lidarr_models_tests.rs +++ b/src/models/lidarr_models_tests.rs @@ -5,7 +5,7 @@ mod tests { use serde_json::json; use crate::models::lidarr_models::{ - DownloadRecord, DownloadStatus, DownloadsResponse, MetadataProfile, NewItemMonitorType, + DownloadRecord, DownloadStatus, DownloadsResponse, Member, MetadataProfile, NewItemMonitorType, SystemStatus, }; use crate::models::servarr_models::{DiskSpace, QualityProfile, RootFolder, Tag}; @@ -74,7 +74,6 @@ mod tests { fn test_artist_deserialization() { let artist_json = json!({ "id": 1, - "mbId": "test-mb-id", "artistName": "Test Artist", "foreignArtistId": "test-foreign-id", "status": "continuing", @@ -82,6 +81,10 @@ mod tests { "artistType": "Group", "disambiguation": "UK Band", "path": "/music/test-artist", + "members": [ + { "name": "alex", "instrument": "piano" }, + { "name": "madi", "instrument": "vocals" } + ], "qualityProfileId": 1, "metadataProfileId": 1, "monitored": true, @@ -102,6 +105,16 @@ mod tests { "percentOfTracks": 83.33 } }); + let expected_members_vec = vec![ + Member { + name: Some("alex".to_string()), + instrument: Some("piano".to_string()), + }, + Member { + name: Some("madi".to_string()), + instrument: Some("vocals".to_string()), + }, + ]; let artist: Artist = serde_json::from_value(artist_json).unwrap(); @@ -113,6 +126,7 @@ mod tests { assert_some_eq_x!(&artist.artist_type, "Group"); assert_some_eq_x!(&artist.disambiguation, "UK Band"); assert_str_eq!(artist.path, "/music/test-artist"); + assert_some_eq_x!(&artist.members, &expected_members_vec); assert_eq!(artist.quality_profile_id, 1); assert_eq!(artist.metadata_profile_id, 1); assert!(artist.monitored); @@ -198,7 +212,6 @@ mod tests { fn test_artist_with_optional_fields_none() { let artist_json = json!({ "id": 1, - "mbId": "", "artistName": "Test Artist", "foreignArtistId": "", "status": "continuing", diff --git a/src/network/lidarr_network/library/lidarr_library_network_tests.rs b/src/network/lidarr_network/library/lidarr_library_network_tests.rs index 4944a8c..5533dba 100644 --- a/src/network/lidarr_network/library/lidarr_library_network_tests.rs +++ b/src/network/lidarr_network/library/lidarr_library_network_tests.rs @@ -11,7 +11,6 @@ mod tests { async fn test_handle_list_artists_event() { let artists_json = json!([{ "id": 1, - "mbId": "test-mb-id", "artistName": "Test Artist", "foreignArtistId": "test-foreign-id", "status": "continuing", @@ -73,18 +72,30 @@ mod tests { async fn test_handle_get_artist_details_event() { let artist_json = json!({ "id": 1, - "mbId": "test-mb-id", "artistName": "Test Artist", "foreignArtistId": "test-foreign-id", "status": "continuing", + "overview": "some interesting description of the artist", + "artistType": "Person", + "disambiguation": "American pianist", "path": "/music/test-artist", + "members": [{"name": "alex", "instrument": "piano"}], "qualityProfileId": 1, "metadataProfileId": 1, "monitored": true, "monitorNewItems": "all", - "genres": [], - "tags": [], - "added": "2023-01-01T00:00:00Z" + "genres": ["soundtrack"], + "tags": [1], + "added": "2023-01-01T00:00:00Z", + "ratings": { "votes": 15, "value": 8.4 }, + "statistics": { + "albumCount": 1, + "trackFileCount": 15, + "trackCount": 15, + "totalTrackCount": 15, + "sizeOnDisk": 12345, + "percentOfTracks": 99.9 + } }); let response: Artist = serde_json::from_value(artist_json.clone()).unwrap(); let (mock, app, _server) = MockServarrApi::get() @@ -112,7 +123,6 @@ mod tests { async fn test_handle_toggle_artist_monitoring_event() { let artist_json = json!({ "id": 1, - "mbId": "test-mb-id", "artistName": "Test Artist", "foreignArtistId": "test-foreign-id", "status": "continuing", -- 2.52.0 From b4a99d16651eb02815a6a1a3001b909c5089f737 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Tue, 6 Jan 2026 10:24:51 -0700 Subject: [PATCH 08/61] feat: Created Lidarr commands: 'get artist-details' and 'get system-status' --- src/cli/lidarr/get_command_handler.rs | 79 ++++++++++++ src/cli/lidarr/get_command_handler_tests.rs | 127 ++++++++++++++++++++ src/cli/lidarr/lidarr_command_tests.rs | 23 ++++ src/cli/lidarr/mod.rs | 12 ++ 4 files changed, 241 insertions(+) create mode 100644 src/cli/lidarr/get_command_handler.rs create mode 100644 src/cli/lidarr/get_command_handler_tests.rs diff --git a/src/cli/lidarr/get_command_handler.rs b/src/cli/lidarr/get_command_handler.rs new file mode 100644 index 0000000..c731212 --- /dev/null +++ b/src/cli/lidarr/get_command_handler.rs @@ -0,0 +1,79 @@ +use std::sync::Arc; + +use anyhow::Result; +use clap::Subcommand; +use tokio::sync::Mutex; + +use crate::{ + app::App, + cli::{CliCommandHandler, Command}, + network::{NetworkTrait, lidarr_network::LidarrEvent}, +}; + +use super::LidarrCommand; + +#[cfg(test)] +#[path = "get_command_handler_tests.rs"] +mod get_command_handler_tests; + +#[derive(Debug, Clone, PartialEq, Eq, Subcommand)] +pub enum LidarrGetCommand { + #[command(about = "Get detailed information for the artist with the given ID")] + ArtistDetails { + #[arg( + long, + help = "The Lidarr ID of the artist whose details you wish to fetch", + required = true + )] + artist_id: i64, + }, + #[command(about = "Get the system status")] + SystemStatus, +} + +impl From for Command { + fn from(value: LidarrGetCommand) -> Self { + Command::Lidarr(LidarrCommand::Get(value)) + } +} + +pub(super) struct LidarrGetCommandHandler<'a, 'b> { + _app: &'a Arc>>, + command: LidarrGetCommand, + network: &'a mut dyn NetworkTrait, +} + +impl<'a, 'b> CliCommandHandler<'a, 'b, LidarrGetCommand> for LidarrGetCommandHandler<'a, 'b> { + fn with( + _app: &'a Arc>>, + command: LidarrGetCommand, + network: &'a mut dyn NetworkTrait, + ) -> Self { + LidarrGetCommandHandler { + _app, + command, + network, + } + } + + async fn handle(self) -> Result { + let result = match self.command { + LidarrGetCommand::ArtistDetails { artist_id } => { + let resp = self + .network + .handle_network_event(LidarrEvent::GetArtistDetails(artist_id).into()) + .await?; + serde_json::to_string_pretty(&resp)? + } + LidarrGetCommand::SystemStatus => { + let resp = self + .network + .handle_network_event(LidarrEvent::GetStatus.into()) + .await?; + serde_json::to_string_pretty(&resp)? + } + }; + + Ok(result) + } +} diff --git a/src/cli/lidarr/get_command_handler_tests.rs b/src/cli/lidarr/get_command_handler_tests.rs new file mode 100644 index 0000000..0474ae6 --- /dev/null +++ b/src/cli/lidarr/get_command_handler_tests.rs @@ -0,0 +1,127 @@ +#[cfg(test)] +mod tests { + use crate::Cli; + use crate::cli::{ + Command, + lidarr::{LidarrCommand, get_command_handler::LidarrGetCommand}, + }; + use clap::CommandFactory; + use pretty_assertions::assert_eq; + + #[test] + fn test_lidarr_get_command_from() { + let command = LidarrGetCommand::SystemStatus; + + let result = Command::from(command.clone()); + + assert_eq!(result, Command::Lidarr(LidarrCommand::Get(command))); + } + + mod cli { + use clap::error::ErrorKind; + use pretty_assertions::assert_eq; + + use super::*; + + #[test] + fn test_artist_details_requires_artist_id() { + let result = + Cli::command().try_get_matches_from(["managarr", "lidarr", "get", "artist-details"]); + + assert_err!(&result); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_artist_details_requirements_satisfied() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "lidarr", + "get", + "artist-details", + "--artist-id", + "1", + ]); + + assert_ok!(&result); + } + + #[test] + fn test_system_status_has_no_arg_requirements() { + let result = + Cli::command().try_get_matches_from(["managarr", "lidarr", "get", "system-status"]); + + assert_ok!(&result); + } + } + + mod handler { + use std::sync::Arc; + + use mockall::predicate::eq; + use serde_json::json; + use tokio::sync::Mutex; + + use crate::{ + app::App, + cli::{ + CliCommandHandler, + lidarr::get_command_handler::{LidarrGetCommand, LidarrGetCommandHandler}, + }, + models::{Serdeable, lidarr_models::LidarrSerdeable}, + network::{MockNetworkTrait, NetworkEvent, lidarr_network::LidarrEvent}, + }; + + #[tokio::test] + async fn test_handle_get_artist_details_command() { + let expected_artist_id = 1; + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::( + LidarrEvent::GetArtistDetails(expected_artist_id).into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Lidarr(LidarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::test_default())); + let get_artist_details_command = LidarrGetCommand::ArtistDetails { artist_id: 1 }; + + let result = + LidarrGetCommandHandler::with(&app_arc, get_artist_details_command, &mut mock_network) + .handle() + .await; + + assert_ok!(&result); + } + + #[tokio::test] + async fn test_handle_get_system_status_command() { + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::(LidarrEvent::GetStatus.into())) + .times(1) + .returning(|_| { + Ok(Serdeable::Lidarr(LidarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::test_default())); + let get_system_status_command = LidarrGetCommand::SystemStatus; + + let result = + LidarrGetCommandHandler::with(&app_arc, get_system_status_command, &mut mock_network) + .handle() + .await; + + assert_ok!(&result); + } + } +} diff --git a/src/cli/lidarr/lidarr_command_tests.rs b/src/cli/lidarr/lidarr_command_tests.rs index dede7cc..2ca231b 100644 --- a/src/cli/lidarr/lidarr_command_tests.rs +++ b/src/cli/lidarr/lidarr_command_tests.rs @@ -91,6 +91,29 @@ mod tests { }, network::{MockNetworkTrait, NetworkEvent, lidarr_network::LidarrEvent}, }; + use crate::cli::lidarr::get_command_handler::LidarrGetCommand; + + #[tokio::test] + async fn test_lidarr_cli_handler_delegates_get_commands_to_the_get_command_handler() { + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::(LidarrEvent::GetStatus.into())) + .times(1) + .returning(|_| { + Ok(Serdeable::Lidarr(LidarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::test_default())); + let get_system_status_command = LidarrCommand::Get(LidarrGetCommand::SystemStatus); + + let result = LidarrCliHandler::with(&app_arc, get_system_status_command, &mut mock_network) + .handle() + .await; + + assert_ok!(&result); + } #[tokio::test] async fn test_lidarr_cli_handler_delegates_delete_commands_to_the_delete_command_handler() { diff --git a/src/cli/lidarr/mod.rs b/src/cli/lidarr/mod.rs index 43f2bed..dc533ab 100644 --- a/src/cli/lidarr/mod.rs +++ b/src/cli/lidarr/mod.rs @@ -3,6 +3,7 @@ use std::sync::Arc; use anyhow::Result; use clap::{Subcommand, arg}; use delete_command_handler::{LidarrDeleteCommand, LidarrDeleteCommandHandler}; +use get_command_handler::{LidarrGetCommand, LidarrGetCommandHandler}; use list_command_handler::{LidarrListCommand, LidarrListCommandHandler}; use tokio::sync::Mutex; @@ -12,6 +13,7 @@ use crate::{app::App, network::NetworkTrait}; use super::{CliCommandHandler, Command}; mod delete_command_handler; +mod get_command_handler; mod list_command_handler; #[cfg(test)] @@ -25,6 +27,11 @@ pub enum LidarrCommand { about = "Commands to delete resources from your Lidarr instance" )] Delete(LidarrDeleteCommand), + #[command( + subcommand, + about = "Commands to fetch details of the resources in your Lidarr instance" + )] + Get(LidarrGetCommand), #[command( subcommand, about = "Commands to list attributes from your Lidarr instance" @@ -75,6 +82,11 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, LidarrCommand> for LidarrCliHandler<'a, ' .handle() .await? } + LidarrCommand::Get(get_command) => { + LidarrGetCommandHandler::with(self.app, get_command, self.network) + .handle() + .await? + } LidarrCommand::List(list_command) => { LidarrListCommandHandler::with(self.app, list_command, self.network) .handle() -- 2.52.0 From 4e13d5d34d87f9e32c19c9f65c139ba58c2bfa68 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Tue, 6 Jan 2026 10:25:05 -0700 Subject: [PATCH 09/61] style: Applied formatting for the lidarr_command_tests --- src/cli/lidarr/lidarr_command_tests.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/cli/lidarr/lidarr_command_tests.rs b/src/cli/lidarr/lidarr_command_tests.rs index 2ca231b..52e1276 100644 --- a/src/cli/lidarr/lidarr_command_tests.rs +++ b/src/cli/lidarr/lidarr_command_tests.rs @@ -76,6 +76,7 @@ mod tests { use serde_json::json; use tokio::sync::Mutex; + use crate::cli::lidarr::get_command_handler::LidarrGetCommand; use crate::{ app::App, cli::{ @@ -91,7 +92,6 @@ mod tests { }, network::{MockNetworkTrait, NetworkEvent, lidarr_network::LidarrEvent}, }; - use crate::cli::lidarr::get_command_handler::LidarrGetCommand; #[tokio::test] async fn test_lidarr_cli_handler_delegates_get_commands_to_the_get_command_handler() { -- 2.52.0 From 96308afeee4a71a24dc543795a693c27b705de9c Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Tue, 6 Jan 2026 11:00:19 -0700 Subject: [PATCH 10/61] feat: Added Lidarr CLI support for fetching the host config and the security config --- src/cli/lidarr/get_command_handler.rs | 18 ++++ src/cli/lidarr/get_command_handler_tests.rs | 62 ++++++++++++ src/models/lidarr_models.rs | 4 +- src/models/lidarr_models_tests.rs | 31 +++++- src/models/servarr_data/lidarr/lidarr_data.rs | 6 +- .../lidarr_network/lidarr_network_tests.rs | 20 ++++ src/network/lidarr_network/mod.rs | 24 +++++ .../system/lidarr_system_network_tests.rs | 94 +++++++++++++++---- src/network/lidarr_network/system/mod.rs | 27 +++++- src/network/network_tests.rs | 19 ++-- 10 files changed, 271 insertions(+), 34 deletions(-) diff --git a/src/cli/lidarr/get_command_handler.rs b/src/cli/lidarr/get_command_handler.rs index c731212..64063b8 100644 --- a/src/cli/lidarr/get_command_handler.rs +++ b/src/cli/lidarr/get_command_handler.rs @@ -27,6 +27,10 @@ pub enum LidarrGetCommand { )] artist_id: i64, }, + #[command(about = "Fetch the host config for your Lidarr instance")] + HostConfig, + #[command(about = "Fetch the security config for your Lidarr instance")] + SecurityConfig, #[command(about = "Get the system status")] SystemStatus, } @@ -65,6 +69,20 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, LidarrGetCommand> for LidarrGetCommandHan .await?; serde_json::to_string_pretty(&resp)? } + LidarrGetCommand::HostConfig => { + let resp = self + .network + .handle_network_event(LidarrEvent::GetHostConfig.into()) + .await?; + serde_json::to_string_pretty(&resp)? + } + LidarrGetCommand::SecurityConfig => { + let resp = self + .network + .handle_network_event(LidarrEvent::GetSecurityConfig.into()) + .await?; + serde_json::to_string_pretty(&resp)? + } LidarrGetCommand::SystemStatus => { let resp = self .network diff --git a/src/cli/lidarr/get_command_handler_tests.rs b/src/cli/lidarr/get_command_handler_tests.rs index 0474ae6..4ada9a7 100644 --- a/src/cli/lidarr/get_command_handler_tests.rs +++ b/src/cli/lidarr/get_command_handler_tests.rs @@ -49,6 +49,22 @@ mod tests { assert_ok!(&result); } + #[test] + fn test_host_config_has_no_arg_requirements() { + let result = + Cli::command().try_get_matches_from(["managarr", "lidarr", "get", "host-config"]); + + assert_ok!(&result); + } + + #[test] + fn test_security_config_has_no_arg_requirements() { + let result = + Cli::command().try_get_matches_from(["managarr", "lidarr", "get", "security-config"]); + + assert_ok!(&result); + } + #[test] fn test_system_status_has_no_arg_requirements() { let result = @@ -101,6 +117,52 @@ mod tests { assert_ok!(&result); } + #[tokio::test] + async fn test_handle_get_host_config_command() { + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::(LidarrEvent::GetHostConfig.into())) + .times(1) + .returning(|_| { + Ok(Serdeable::Lidarr(LidarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::test_default())); + let get_host_config_command = LidarrGetCommand::HostConfig; + + let result = + LidarrGetCommandHandler::with(&app_arc, get_host_config_command, &mut mock_network) + .handle() + .await; + + assert_ok!(&result); + } + + #[tokio::test] + async fn test_handle_get_security_config_command() { + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::(LidarrEvent::GetSecurityConfig.into())) + .times(1) + .returning(|_| { + Ok(Serdeable::Lidarr(LidarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::test_default())); + let get_security_config_command = LidarrGetCommand::SecurityConfig; + + let result = + LidarrGetCommandHandler::with(&app_arc, get_security_config_command, &mut mock_network) + .handle() + .await; + + assert_ok!(&result); + } + #[tokio::test] async fn test_handle_get_system_status_command() { let mut mock_network = MockNetworkTrait::new(); diff --git a/src/models/lidarr_models.rs b/src/models/lidarr_models.rs index 0cc6884..945c689 100644 --- a/src/models/lidarr_models.rs +++ b/src/models/lidarr_models.rs @@ -7,7 +7,7 @@ use strum::{Display, EnumIter}; use super::{ HorizontallyScrollableText, Serdeable, - servarr_models::{DiskSpace, QualityProfile, RootFolder, Tag}, + servarr_models::{DiskSpace, HostConfig, QualityProfile, RootFolder, SecurityConfig, Tag}, }; use crate::serde_enum_from; @@ -217,9 +217,11 @@ serde_enum_from!( Artists(Vec), DiskSpaces(Vec), DownloadsResponse(DownloadsResponse), + HostConfig(HostConfig), MetadataProfiles(Vec), QualityProfiles(Vec), RootFolders(Vec), + SecurityConfig(SecurityConfig), SystemStatus(SystemStatus), Tags(Vec), Value(Value), diff --git a/src/models/lidarr_models_tests.rs b/src/models/lidarr_models_tests.rs index a280538..bb3f478 100644 --- a/src/models/lidarr_models_tests.rs +++ b/src/models/lidarr_models_tests.rs @@ -8,7 +8,9 @@ mod tests { DownloadRecord, DownloadStatus, DownloadsResponse, Member, MetadataProfile, NewItemMonitorType, SystemStatus, }; - use crate::models::servarr_models::{DiskSpace, QualityProfile, RootFolder, Tag}; + use crate::models::servarr_models::{ + DiskSpace, HostConfig, QualityProfile, RootFolder, SecurityConfig, Tag, + }; use crate::models::{ Serdeable, lidarr_models::{Artist, ArtistStatistics, ArtistStatus, LidarrSerdeable, Ratings}, @@ -291,6 +293,18 @@ mod tests { ); } + #[test] + fn test_lidarr_serdeable_from_host_config() { + let host_config = HostConfig { + port: 8686, + ..HostConfig::default() + }; + + let lidarr_serdeable: LidarrSerdeable = host_config.clone().into(); + + assert_eq!(lidarr_serdeable, LidarrSerdeable::HostConfig(host_config)); + } + #[test] fn test_lidarr_serdeable_from_quality_profiles() { let quality_profiles = vec![QualityProfile { @@ -321,6 +335,21 @@ mod tests { assert_eq!(lidarr_serdeable, LidarrSerdeable::RootFolders(root_folders)); } + #[test] + fn test_lidarr_serdeable_from_security_config() { + let security_config = SecurityConfig { + api_key: "test-key".to_owned(), + ..SecurityConfig::default() + }; + + let lidarr_serdeable: LidarrSerdeable = security_config.clone().into(); + + assert_eq!( + lidarr_serdeable, + LidarrSerdeable::SecurityConfig(security_config) + ); + } + #[test] fn test_lidarr_serdeable_from_system_status() { let system_status = SystemStatus { diff --git a/src/models/servarr_data/lidarr/lidarr_data.rs b/src/models/servarr_data/lidarr/lidarr_data.rs index 5d87459..01b0bed 100644 --- a/src/models/servarr_data/lidarr/lidarr_data.rs +++ b/src/models/servarr_data/lidarr/lidarr_data.rs @@ -1,6 +1,6 @@ use crate::app::lidarr::lidarr_context_clues::ARTISTS_CONTEXT_CLUES; use crate::models::{ - Route, TabRoute, TabState, + BlockSelectionState, Route, TabRoute, TabState, lidarr_models::{Artist, DownloadRecord}, servarr_models::{DiskSpace, RootFolder}, stateful_table::StatefulTable, @@ -28,7 +28,7 @@ pub struct LidarrData<'a> { pub prompt_confirm_action: Option, pub quality_profile_map: BiMap, pub root_folders: StatefulTable, - pub selected_block: crate::models::BlockSelectionState<'a, ActiveLidarrBlock>, + pub selected_block: BlockSelectionState<'a, ActiveLidarrBlock>, pub start_time: DateTime, pub tags_map: BiMap, pub version: String, @@ -54,7 +54,7 @@ impl<'a> Default for LidarrData<'a> { prompt_confirm_action: None, quality_profile_map: BiMap::new(), root_folders: StatefulTable::default(), - selected_block: crate::models::BlockSelectionState::default(), + selected_block: BlockSelectionState::default(), start_time: Utc::now(), tags_map: BiMap::new(), version: String::new(), diff --git a/src/network/lidarr_network/lidarr_network_tests.rs b/src/network/lidarr_network/lidarr_network_tests.rs index f97f4e5..3a43da1 100644 --- a/src/network/lidarr_network/lidarr_network_tests.rs +++ b/src/network/lidarr_network/lidarr_network_tests.rs @@ -20,6 +20,13 @@ mod tests { assert_str_eq!(event.resource(), "/artist"); } + #[rstest] + fn test_resource_config( + #[values(LidarrEvent::GetHostConfig, LidarrEvent::GetSecurityConfig)] event: LidarrEvent, + ) { + assert_str_eq!(event.resource(), "/config/host"); + } + #[rstest] #[case(LidarrEvent::GetDiskSpace, "/diskspace")] #[case(LidarrEvent::GetDownloads(500), "/queue")] @@ -41,6 +48,19 @@ mod tests { ); } + #[tokio::test] + async fn test_handle_get_lidarr_healthcheck_event() { + let (mock, app, _server) = MockServarrApi::get() + .build_for(LidarrEvent::HealthCheck) + .await; + app.lock().await.server_tabs.set_index(2); + let mut network = test_network(&app); + + let _ = network.handle_lidarr_event(LidarrEvent::HealthCheck).await; + + mock.assert_async().await; + } + #[tokio::test] async fn test_handle_get_metadata_profiles_event() { let metadata_profiles_json = json!([{ diff --git a/src/network/lidarr_network/mod.rs b/src/network/lidarr_network/mod.rs index db62bef..af16346 100644 --- a/src/network/lidarr_network/mod.rs +++ b/src/network/lidarr_network/mod.rs @@ -21,9 +21,11 @@ pub enum LidarrEvent { GetArtistDetails(i64), GetDiskSpace, GetDownloads(u64), + GetHostConfig, GetMetadataProfiles, GetQualityProfiles, GetRootFolders, + GetSecurityConfig, GetStatus, GetTags, HealthCheck, @@ -40,6 +42,7 @@ impl NetworkResource for LidarrEvent { | LidarrEvent::ToggleArtistMonitoring(_) => "/artist", LidarrEvent::GetDiskSpace => "/diskspace", LidarrEvent::GetDownloads(_) => "/queue", + LidarrEvent::GetHostConfig | LidarrEvent::GetSecurityConfig => "/config/host", LidarrEvent::GetMetadataProfiles => "/metadataprofile", LidarrEvent::GetQualityProfiles => "/qualityprofile", LidarrEvent::GetRootFolders => "/rootfolder", @@ -74,6 +77,10 @@ impl Network<'_, '_> { .get_lidarr_downloads(count) .await .map(LidarrSerdeable::from), + LidarrEvent::GetHostConfig => self + .get_lidarr_host_config() + .await + .map(LidarrSerdeable::from), LidarrEvent::GetMetadataProfiles => self .get_lidarr_metadata_profiles() .await @@ -86,6 +93,10 @@ impl Network<'_, '_> { .get_lidarr_root_folders() .await .map(LidarrSerdeable::from), + LidarrEvent::GetSecurityConfig => self + .get_lidarr_security_config() + .await + .map(LidarrSerdeable::from), LidarrEvent::GetStatus => self.get_lidarr_status().await.map(LidarrSerdeable::from), LidarrEvent::GetTags => self.get_lidarr_tags().await.map(LidarrSerdeable::from), LidarrEvent::HealthCheck => self @@ -100,6 +111,19 @@ impl Network<'_, '_> { } } + pub(in crate::network::lidarr_network) async fn get_lidarr_healthcheck(&mut self) -> Result<()> { + info!("Performing Lidarr health check"); + let event = LidarrEvent::HealthCheck; + + let request_props = self + .request_props_from(event, RequestMethod::Get, None::<()>, None, None) + .await; + + self + .handle_request::<(), ()>(request_props, |_, _| ()) + .await + } + async fn get_lidarr_metadata_profiles(&mut self) -> Result> { info!("Fetching Lidarr metadata profiles"); let event = LidarrEvent::GetMetadataProfiles; diff --git a/src/network/lidarr_network/system/lidarr_system_network_tests.rs b/src/network/lidarr_network/system/lidarr_system_network_tests.rs index 1457437..2c9f52c 100644 --- a/src/network/lidarr_network/system/lidarr_system_network_tests.rs +++ b/src/network/lidarr_network/system/lidarr_system_network_tests.rs @@ -1,31 +1,24 @@ #[cfg(test)] mod tests { use crate::models::lidarr_models::{LidarrSerdeable, SystemStatus}; - use crate::models::servarr_models::DiskSpace; + use crate::models::servarr_models::{DiskSpace, HostConfig, SecurityConfig}; use crate::network::lidarr_network::LidarrEvent; use crate::network::network_tests::test_utils::{MockServarrApi, test_network}; use pretty_assertions::assert_eq; use serde_json::json; - #[tokio::test] - async fn test_handle_get_lidarr_healthcheck_event() { - let (mock, app, _server) = MockServarrApi::get() - .build_for(LidarrEvent::HealthCheck) - .await; - app.lock().await.server_tabs.set_index(2); - let mut network = test_network(&app); - - let _ = network.handle_lidarr_event(LidarrEvent::HealthCheck).await; - - mock.assert_async().await; - } - #[tokio::test] async fn test_handle_get_diskspace_event() { - let diskspace_json = json!([{ - "freeSpace": 50000000000i64, - "totalSpace": 100000000000i64 - }]); + let diskspace_json = json!([ + { + "freeSpace": 1111, + "totalSpace": 2222, + }, + { + "freeSpace": 3333, + "totalSpace": 4444 + } + ]); let response: Vec = serde_json::from_value(diskspace_json.clone()).unwrap(); let (mock, app, _server) = MockServarrApi::get() .returns(diskspace_json) @@ -46,6 +39,71 @@ mod tests { assert!(!app.lock().await.data.lidarr_data.disk_space_vec.is_empty()); } + #[tokio::test] + async fn test_handle_get_host_config_event() { + let host_config_json = json!({ + "bindAddress": "*", + "port": 8686, + "urlBase": "some.test.site/lidarr", + "instanceName": "Lidarr", + "applicationUrl": "https://some.test.site:8686/lidarr", + "enableSsl": true, + "sslPort": 6868, + "sslCertPath": "/app/lidarr.pfx", + "sslCertPassword": "test" + }); + let response: HostConfig = serde_json::from_value(host_config_json.clone()).unwrap(); + let (mock, app, _server) = MockServarrApi::get() + .returns(host_config_json) + .build_for(LidarrEvent::GetHostConfig) + .await; + app.lock().await.server_tabs.set_index(2); + let mut network = test_network(&app); + + let result = network + .handle_lidarr_event(LidarrEvent::GetHostConfig) + .await; + + mock.assert_async().await; + + let LidarrSerdeable::HostConfig(host_config) = result.unwrap() else { + panic!("Expected HostConfig"); + }; + + assert_eq!(host_config, response); + } + + #[tokio::test] + async fn test_handle_get_security_config_event() { + let security_config_json = json!({ + "authenticationMethod": "forms", + "authenticationRequired": "disabledForLocalAddresses", + "username": "test", + "password": "some password", + "apiKey": "someApiKey12345", + "certificateValidation": "disabledForLocalAddresses" + }); + let response: SecurityConfig = serde_json::from_value(security_config_json.clone()).unwrap(); + let (mock, app, _server) = MockServarrApi::get() + .returns(security_config_json) + .build_for(LidarrEvent::GetSecurityConfig) + .await; + app.lock().await.server_tabs.set_index(2); + let mut network = test_network(&app); + + let result = network + .handle_lidarr_event(LidarrEvent::GetSecurityConfig) + .await; + + mock.assert_async().await; + + let LidarrSerdeable::SecurityConfig(security_config) = result.unwrap() else { + panic!("Expected SecurityConfig"); + }; + + assert_eq!(security_config, response); + } + #[tokio::test] async fn test_handle_get_status_event() { let status_json = json!({ diff --git a/src/network/lidarr_network/system/mod.rs b/src/network/lidarr_network/system/mod.rs index 2cb9196..1b33ac2 100644 --- a/src/network/lidarr_network/system/mod.rs +++ b/src/network/lidarr_network/system/mod.rs @@ -2,7 +2,7 @@ use anyhow::Result; use log::info; use crate::models::lidarr_models::SystemStatus; -use crate::models::servarr_models::DiskSpace; +use crate::models::servarr_models::{DiskSpace, HostConfig, SecurityConfig}; use crate::network::lidarr_network::LidarrEvent; use crate::network::{Network, RequestMethod}; @@ -11,16 +11,33 @@ use crate::network::{Network, RequestMethod}; mod lidarr_system_network_tests; impl Network<'_, '_> { - pub(in crate::network::lidarr_network) async fn get_lidarr_healthcheck(&mut self) -> Result<()> { - info!("Performing Lidarr health check"); - let event = LidarrEvent::HealthCheck; + pub(in crate::network::lidarr_network) async fn get_lidarr_host_config( + &mut self, + ) -> Result { + info!("Fetching Lidarr host config"); + let event = LidarrEvent::GetHostConfig; let request_props = self .request_props_from(event, RequestMethod::Get, None::<()>, None, None) .await; self - .handle_request::<(), ()>(request_props, |_, _| ()) + .handle_request::<(), HostConfig>(request_props, |_, _| ()) + .await + } + + pub(in crate::network::lidarr_network) async fn get_lidarr_security_config( + &mut self, + ) -> Result { + info!("Fetching Lidarr security config"); + let event = LidarrEvent::GetSecurityConfig; + + let request_props = self + .request_props_from(event, RequestMethod::Get, None::<()>, None, None) + .await; + + self + .handle_request::<(), SecurityConfig>(request_props, |_, _| ()) .await } diff --git a/src/network/network_tests.rs b/src/network/network_tests.rs index 7e7abc2..871e9d6 100644 --- a/src/network/network_tests.rs +++ b/src/network/network_tests.rs @@ -18,6 +18,7 @@ mod tests { use crate::app::{App, AppConfig, ServarrConfig}; use crate::models::HorizontallyScrollableText; use crate::network::NetworkResource; + use crate::network::lidarr_network::LidarrEvent; use crate::network::network_tests::test_utils::test_network; use crate::network::radarr_network::RadarrEvent; use crate::network::sonarr_network::SonarrEvent; @@ -421,11 +422,13 @@ mod tests { } #[rstest] - #[case(RadarrEvent::GetMovies, 7878)] - #[case(SonarrEvent::ListSeries, 8989)] + #[case(RadarrEvent::GetMovies, "v3", 7878)] + #[case(SonarrEvent::ListSeries, "v3", 8989)] + #[case(LidarrEvent::ListArtists, "v1", 8686)] #[tokio::test] async fn test_request_props_from_default_config( #[case] network_event: impl Into + NetworkResource, + #[case] api_version: &str, #[case] default_port: u16, ) { let app_arc = Arc::new(Mutex::new(App::test_default())); @@ -435,6 +438,7 @@ mod tests { let mut app = app_arc.lock().await; app.server_tabs.tabs[0].config = Some(ServarrConfig::default()); app.server_tabs.tabs[1].config = Some(ServarrConfig::default()); + app.server_tabs.tabs[2].config = Some(ServarrConfig::default()); } let request_props = network @@ -443,7 +447,7 @@ mod tests { assert_str_eq!( request_props.uri, - format!("http://localhost:{default_port}/api/v3{resource}") + format!("http://localhost:{default_port}/api/{api_version}{resource}") ); assert_eq!(request_props.method, RequestMethod::Get); assert_eq!(request_props.body, None); @@ -564,11 +568,13 @@ mod tests { } #[rstest] - #[case(RadarrEvent::GetMovies, 7878)] - #[case(SonarrEvent::ListSeries, 8989)] + #[case(RadarrEvent::GetMovies, "v3", 7878)] + #[case(SonarrEvent::ListSeries, "v3", 8989)] + #[case(LidarrEvent::ListArtists, "v1", 8686)] #[tokio::test] async fn test_request_props_from_default_config_with_path_and_query_params( #[case] network_event: impl Into + NetworkResource, + #[case] api_version: &str, #[case] default_port: u16, ) { let app_arc = Arc::new(Mutex::new(App::test_default())); @@ -578,6 +584,7 @@ mod tests { let mut app = app_arc.lock().await; app.server_tabs.tabs[0].config = Some(ServarrConfig::default()); app.server_tabs.tabs[1].config = Some(ServarrConfig::default()); + app.server_tabs.tabs[2].config = Some(ServarrConfig::default()); } let request_props = network @@ -592,7 +599,7 @@ mod tests { assert_str_eq!( request_props.uri, - format!("http://localhost:{default_port}/api/v3{resource}/test?id=1") + format!("http://localhost:{default_port}/api/{api_version}{resource}/test?id=1") ); assert_eq!(request_props.method, RequestMethod::Get); assert_eq!(request_props.body, None); -- 2.52.0 From 9b4eda6a9d65f3a3c2d833289a132cfcfd69a7b2 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Tue, 6 Jan 2026 12:47:10 -0700 Subject: [PATCH 11/61] feat: Support for updating all Lidarr artists in both the CLI and TUI --- src/app/lidarr/lidarr_context_clues.rs | 3 +- src/app/lidarr/lidarr_context_clues_tests.rs | 4 + src/cli/lidarr/lidarr_command_tests.rs | 23 +++ src/cli/lidarr/mod.rs | 12 ++ src/cli/lidarr/refresh_command_handler.rs | 64 +++++++ .../lidarr/refresh_command_handler_tests.rs | 72 ++++++++ .../library/library_handler_tests.rs | 171 +++++++++++++++++- src/handlers/lidarr_handlers/library/mod.rs | 46 ++++- src/models/servarr_data/lidarr/lidarr_data.rs | 14 +- .../servarr_data/lidarr/lidarr_data_tests.rs | 87 ++++++++- src/models/servarr_data/sonarr/modals.rs | 3 + .../servarr_data/sonarr/sonarr_data_tests.rs | 133 ++++++-------- .../library/lidarr_library_network_tests.rs | 22 +++ src/network/lidarr_network/library/mod.rs | 17 ++ .../lidarr_network/lidarr_network_tests.rs | 5 + src/network/lidarr_network/mod.rs | 3 + src/ui/lidarr_ui/library/library_ui_tests.rs | 13 ++ src/ui/lidarr_ui/library/mod.rs | 20 +- ..._ui_renders_update_all_artists_prompt.snap | 38 ++++ src/ui/sonarr_ui/library/library_ui_tests.rs | 13 ++ ...y_ui_renders_update_all_series_prompt.snap | 38 ++++ 21 files changed, 701 insertions(+), 100 deletions(-) create mode 100644 src/cli/lidarr/refresh_command_handler.rs create mode 100644 src/cli/lidarr/refresh_command_handler_tests.rs create mode 100644 src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_update_all_artists_prompt.snap create mode 100644 src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_update_all_series_prompt.snap diff --git a/src/app/lidarr/lidarr_context_clues.rs b/src/app/lidarr/lidarr_context_clues.rs index cf0b3c9..7c696c2 100644 --- a/src/app/lidarr/lidarr_context_clues.rs +++ b/src/app/lidarr/lidarr_context_clues.rs @@ -7,7 +7,7 @@ use crate::models::Route; #[path = "lidarr_context_clues_tests.rs"] mod lidarr_context_clues_tests; -pub static ARTISTS_CONTEXT_CLUES: [ContextClue; 7] = [ +pub static ARTISTS_CONTEXT_CLUES: [ContextClue; 8] = [ ( DEFAULT_KEYBINDINGS.toggle_monitoring, DEFAULT_KEYBINDINGS.toggle_monitoring.desc, @@ -20,6 +20,7 @@ pub static ARTISTS_CONTEXT_CLUES: [ContextClue; 7] = [ DEFAULT_KEYBINDINGS.refresh, DEFAULT_KEYBINDINGS.refresh.desc, ), + (DEFAULT_KEYBINDINGS.update, "update all"), (DEFAULT_KEYBINDINGS.esc, "cancel filter"), ]; diff --git a/src/app/lidarr/lidarr_context_clues_tests.rs b/src/app/lidarr/lidarr_context_clues_tests.rs index 542469a..b219954 100644 --- a/src/app/lidarr/lidarr_context_clues_tests.rs +++ b/src/app/lidarr/lidarr_context_clues_tests.rs @@ -43,6 +43,10 @@ mod tests { DEFAULT_KEYBINDINGS.refresh.desc ) ); + assert_some_eq_x!( + artists_context_clues_iter.next(), + &(DEFAULT_KEYBINDINGS.update, "update all") + ); assert_some_eq_x!( artists_context_clues_iter.next(), &(DEFAULT_KEYBINDINGS.esc, "cancel filter") diff --git a/src/cli/lidarr/lidarr_command_tests.rs b/src/cli/lidarr/lidarr_command_tests.rs index 52e1276..484c62d 100644 --- a/src/cli/lidarr/lidarr_command_tests.rs +++ b/src/cli/lidarr/lidarr_command_tests.rs @@ -77,6 +77,7 @@ mod tests { use tokio::sync::Mutex; use crate::cli::lidarr::get_command_handler::LidarrGetCommand; + use crate::cli::lidarr::refresh_command_handler::LidarrRefreshCommand; use crate::{ app::App, cli::{ @@ -170,6 +171,28 @@ mod tests { assert_ok!(&result); } + #[tokio::test] + async fn test_lidarr_cli_handler_delegates_refresh_commands_to_the_refresh_command_handler() { + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::(LidarrEvent::UpdateAllArtists.into())) + .times(1) + .returning(|_| { + Ok(Serdeable::Lidarr(LidarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::test_default())); + let refresh_series_command = LidarrCommand::Refresh(LidarrRefreshCommand::AllArtists); + + let result = LidarrCliHandler::with(&app_arc, refresh_series_command, &mut mock_network) + .handle() + .await; + + assert_ok!(&result); + } + #[tokio::test] async fn test_toggle_artist_monitoring_command() { let mut mock_network = MockNetworkTrait::new(); diff --git a/src/cli/lidarr/mod.rs b/src/cli/lidarr/mod.rs index dc533ab..fcc37c8 100644 --- a/src/cli/lidarr/mod.rs +++ b/src/cli/lidarr/mod.rs @@ -5,6 +5,7 @@ use clap::{Subcommand, arg}; use delete_command_handler::{LidarrDeleteCommand, LidarrDeleteCommandHandler}; use get_command_handler::{LidarrGetCommand, LidarrGetCommandHandler}; use list_command_handler::{LidarrListCommand, LidarrListCommandHandler}; +use refresh_command_handler::{LidarrRefreshCommand, LidarrRefreshCommandHandler}; use tokio::sync::Mutex; use crate::network::lidarr_network::LidarrEvent; @@ -15,6 +16,7 @@ use super::{CliCommandHandler, Command}; mod delete_command_handler; mod get_command_handler; mod list_command_handler; +mod refresh_command_handler; #[cfg(test)] #[path = "lidarr_command_tests.rs"] @@ -37,6 +39,11 @@ pub enum LidarrCommand { about = "Commands to list attributes from your Lidarr instance" )] List(LidarrListCommand), + #[command( + subcommand, + about = "Commands to refresh the data in your Lidarr instance" + )] + Refresh(LidarrRefreshCommand), #[command( about = "Toggle monitoring for the specified artist corresponding to the given artist ID" )] @@ -92,6 +99,11 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, LidarrCommand> for LidarrCliHandler<'a, ' .handle() .await? } + LidarrCommand::Refresh(refresh_command) => { + LidarrRefreshCommandHandler::with(self.app, refresh_command, self.network) + .handle() + .await? + } LidarrCommand::ToggleArtistMonitoring { artist_id } => { let resp = self .network diff --git a/src/cli/lidarr/refresh_command_handler.rs b/src/cli/lidarr/refresh_command_handler.rs new file mode 100644 index 0000000..60c23cf --- /dev/null +++ b/src/cli/lidarr/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::{NetworkTrait, lidarr_network::LidarrEvent}, +}; + +use super::LidarrCommand; + +#[cfg(test)] +#[path = "refresh_command_handler_tests.rs"] +mod refresh_command_handler_tests; + +#[derive(Debug, Clone, PartialEq, Eq, Subcommand)] +pub enum LidarrRefreshCommand { + #[command(about = "Refresh all artist data for all artists in your Lidarr library")] + AllArtists, +} + +impl From for Command { + fn from(value: LidarrRefreshCommand) -> Self { + Command::Lidarr(LidarrCommand::Refresh(value)) + } +} + +pub(super) struct LidarrRefreshCommandHandler<'a, 'b> { + _app: &'a Arc>>, + command: LidarrRefreshCommand, + network: &'a mut dyn NetworkTrait, +} + +impl<'a, 'b> CliCommandHandler<'a, 'b, LidarrRefreshCommand> + for LidarrRefreshCommandHandler<'a, 'b> +{ + fn with( + _app: &'a Arc>>, + command: LidarrRefreshCommand, + network: &'a mut dyn NetworkTrait, + ) -> Self { + LidarrRefreshCommandHandler { + _app, + command, + network, + } + } + + async fn handle(self) -> anyhow::Result { + let result = match self.command { + LidarrRefreshCommand::AllArtists => { + let resp = self + .network + .handle_network_event(LidarrEvent::UpdateAllArtists.into()) + .await?; + serde_json::to_string_pretty(&resp)? + } + }; + + Ok(result) + } +} diff --git a/src/cli/lidarr/refresh_command_handler_tests.rs b/src/cli/lidarr/refresh_command_handler_tests.rs new file mode 100644 index 0000000..5efdde8 --- /dev/null +++ b/src/cli/lidarr/refresh_command_handler_tests.rs @@ -0,0 +1,72 @@ +#[cfg(test)] +mod tests { + use pretty_assertions::assert_eq; + + use crate::Cli; + use crate::cli::{ + Command, + lidarr::{LidarrCommand, refresh_command_handler::LidarrRefreshCommand}, + }; + use clap::CommandFactory; + + #[test] + fn test_lidarr_refresh_command_from() { + let command = LidarrRefreshCommand::AllArtists; + + let result = Command::from(command.clone()); + + assert_eq!(result, Command::Lidarr(LidarrCommand::Refresh(command))); + } + + mod cli { + use super::*; + + #[test] + fn test_refresh_all_artists_has_no_arg_requirements() { + let result = + Cli::command().try_get_matches_from(["managarr", "lidarr", "refresh", "all-artists"]); + + assert_ok!(&result); + } + } + + mod handler { + use std::sync::Arc; + + use mockall::predicate::eq; + use serde_json::json; + use tokio::sync::Mutex; + + use crate::{app::App, cli::lidarr::refresh_command_handler::LidarrRefreshCommandHandler}; + use crate::{ + cli::{CliCommandHandler, lidarr::refresh_command_handler::LidarrRefreshCommand}, + network::lidarr_network::LidarrEvent, + }; + use crate::{ + models::{Serdeable, lidarr_models::LidarrSerdeable}, + network::{MockNetworkTrait, NetworkEvent}, + }; + + #[tokio::test] + async fn test_handle_refresh_all_artists_command() { + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::(LidarrEvent::UpdateAllArtists.into())) + .times(1) + .returning(|_| { + Ok(Serdeable::Lidarr(LidarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::test_default())); + let refresh_command = LidarrRefreshCommand::AllArtists; + + let result = LidarrRefreshCommandHandler::with(&app_arc, refresh_command, &mut mock_network) + .handle() + .await; + + assert_ok!(&result); + } + } +} diff --git a/src/handlers/lidarr_handlers/library/library_handler_tests.rs b/src/handlers/lidarr_handlers/library/library_handler_tests.rs index f3bdc24..a987d04 100644 --- a/src/handlers/lidarr_handlers/library/library_handler_tests.rs +++ b/src/handlers/lidarr_handlers/library/library_handler_tests.rs @@ -2,18 +2,18 @@ mod tests { use std::cmp::Ordering; - use pretty_assertions::assert_str_eq; + use pretty_assertions::{assert_eq, assert_str_eq}; use serde_json::Number; use strum::IntoEnumIterator; use crate::app::App; use crate::app::key_binding::DEFAULT_KEYBINDINGS; - use crate::assert_modal_absent; use crate::handlers::KeyEventHandler; use crate::handlers::lidarr_handlers::library::{LibraryHandler, artists_sorting_options}; use crate::models::lidarr_models::{Artist, ArtistStatistics, ArtistStatus}; use crate::models::servarr_data::lidarr::lidarr_data::{ActiveLidarrBlock, LIBRARY_BLOCKS}; use crate::network::lidarr_network::LidarrEvent; + use crate::{assert_modal_absent, assert_navigation_popped, assert_navigation_pushed}; #[test] fn test_library_handler_accepts() { @@ -267,6 +267,173 @@ mod tests { assert!(!app.is_routing); } + #[test] + fn test_update_all_artists_key() { + let mut app = App::test_default(); + app + .data + .lidarr_data + .artists + .set_items(vec![Artist::default()]); + + LibraryHandler::new( + DEFAULT_KEYBINDINGS.update.key, + &mut app, + ActiveLidarrBlock::Artists, + None, + ) + .handle(); + + assert_navigation_pushed!(app, ActiveLidarrBlock::UpdateAllArtistsPrompt.into()); + } + + #[test] + fn test_update_all_artists_key_no_op_when_not_ready() { + let mut app = App::test_default(); + app.is_loading = true; + app.push_navigation_stack(ActiveLidarrBlock::Artists.into()); + app + .data + .lidarr_data + .artists + .set_items(vec![Artist::default()]); + + LibraryHandler::new( + DEFAULT_KEYBINDINGS.update.key, + &mut app, + ActiveLidarrBlock::Artists, + None, + ) + .handle(); + + assert_eq!(app.get_current_route(), ActiveLidarrBlock::Artists.into()); + } + + #[test] + fn test_update_all_artists_prompt_confirm_submit() { + let mut app = App::test_default(); + app + .data + .lidarr_data + .artists + .set_items(vec![Artist::default()]); + app.data.lidarr_data.prompt_confirm = true; + app.push_navigation_stack(ActiveLidarrBlock::Artists.into()); + app.push_navigation_stack(ActiveLidarrBlock::UpdateAllArtistsPrompt.into()); + + LibraryHandler::new( + DEFAULT_KEYBINDINGS.submit.key, + &mut app, + ActiveLidarrBlock::UpdateAllArtistsPrompt, + None, + ) + .handle(); + + assert!(app.data.lidarr_data.prompt_confirm); + assert_some_eq_x!( + &app.data.lidarr_data.prompt_confirm_action, + &LidarrEvent::UpdateAllArtists + ); + assert_navigation_popped!(app, ActiveLidarrBlock::Artists.into()); + } + + #[test] + fn test_update_all_artists_prompt_decline_submit() { + let mut app = App::test_default(); + app + .data + .lidarr_data + .artists + .set_items(vec![Artist::default()]); + app.push_navigation_stack(ActiveLidarrBlock::Artists.into()); + app.push_navigation_stack(ActiveLidarrBlock::UpdateAllArtistsPrompt.into()); + + LibraryHandler::new( + DEFAULT_KEYBINDINGS.submit.key, + &mut app, + ActiveLidarrBlock::UpdateAllArtistsPrompt, + None, + ) + .handle(); + + assert!(!app.data.lidarr_data.prompt_confirm); + assert_none!(app.data.lidarr_data.prompt_confirm_action); + assert_navigation_popped!(app, ActiveLidarrBlock::Artists.into()); + } + + #[test] + fn test_update_all_artists_prompt_esc() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveLidarrBlock::Artists.into()); + app.push_navigation_stack(ActiveLidarrBlock::UpdateAllArtistsPrompt.into()); + app.data.lidarr_data.prompt_confirm = true; + + LibraryHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveLidarrBlock::UpdateAllArtistsPrompt, + None, + ) + .handle(); + + assert_navigation_popped!(app, ActiveLidarrBlock::Artists.into()); + assert!(!app.data.lidarr_data.prompt_confirm); + } + + #[test] + fn test_update_all_artists_prompt_left_right() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveLidarrBlock::Artists.into()); + app.push_navigation_stack(ActiveLidarrBlock::UpdateAllArtistsPrompt.into()); + + LibraryHandler::new( + DEFAULT_KEYBINDINGS.left.key, + &mut app, + ActiveLidarrBlock::UpdateAllArtistsPrompt, + None, + ) + .handle(); + + assert!(app.data.lidarr_data.prompt_confirm); + + LibraryHandler::new( + DEFAULT_KEYBINDINGS.right.key, + &mut app, + ActiveLidarrBlock::UpdateAllArtistsPrompt, + None, + ) + .handle(); + + assert!(!app.data.lidarr_data.prompt_confirm); + } + + #[test] + fn test_update_all_artists_prompt_confirm_key() { + let mut app = App::test_default(); + app + .data + .lidarr_data + .artists + .set_items(vec![Artist::default()]); + app.push_navigation_stack(ActiveLidarrBlock::Artists.into()); + app.push_navigation_stack(ActiveLidarrBlock::UpdateAllArtistsPrompt.into()); + + LibraryHandler::new( + DEFAULT_KEYBINDINGS.confirm.key, + &mut app, + ActiveLidarrBlock::UpdateAllArtistsPrompt, + None, + ) + .handle(); + + assert!(app.data.lidarr_data.prompt_confirm); + assert_some_eq_x!( + &app.data.lidarr_data.prompt_confirm_action, + &LidarrEvent::UpdateAllArtists + ); + assert_navigation_popped!(app, ActiveLidarrBlock::Artists.into()); + } + fn artists_vec() -> Vec { vec![ Artist { diff --git a/src/handlers/lidarr_handlers/library/mod.rs b/src/handlers/lidarr_handlers/library/mod.rs index 82c87c0..d9e4482 100644 --- a/src/handlers/lidarr_handlers/library/mod.rs +++ b/src/handlers/lidarr_handlers/library/mod.rs @@ -1,7 +1,7 @@ use crate::{ app::App, event::Key, - handlers::{KeyEventHandler, handle_clear_errors}, + handlers::{KeyEventHandler, handle_clear_errors, handle_prompt_toggle}, matches_key, models::{ BlockSelectionState, @@ -108,21 +108,39 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveLidarrBlock> for LibraryHandler<'a, ' } fn handle_left_right_action(&mut self) { - if self.active_lidarr_block == ActiveLidarrBlock::Artists { - handle_change_tab_left_right_keys(self.app, self.key); + match self.active_lidarr_block { + ActiveLidarrBlock::Artists => handle_change_tab_left_right_keys(self.app, self.key), + ActiveLidarrBlock::UpdateAllArtistsPrompt => handle_prompt_toggle(self.app, self.key), + _ => (), } } - fn handle_submit(&mut self) {} + fn handle_submit(&mut self) { + if self.active_lidarr_block == ActiveLidarrBlock::UpdateAllArtistsPrompt { + if self.app.data.lidarr_data.prompt_confirm { + self.app.data.lidarr_data.prompt_confirm_action = Some(LidarrEvent::UpdateAllArtists); + } + + self.app.pop_navigation_stack(); + } + } fn handle_esc(&mut self) { - handle_clear_errors(self.app); + match self.active_lidarr_block { + ActiveLidarrBlock::UpdateAllArtistsPrompt => { + self.app.pop_navigation_stack(); + self.app.data.lidarr_data.prompt_confirm = false; + } + _ => { + handle_clear_errors(self.app); + } + } } fn handle_char_key_event(&mut self) { let key = self.key; - if self.active_lidarr_block == ActiveLidarrBlock::Artists { - match key { + match self.active_lidarr_block { + ActiveLidarrBlock::Artists => match key { _ if matches_key!(toggle_monitoring, key) => { self.app.data.lidarr_data.prompt_confirm = true; self.app.data.lidarr_data.prompt_confirm_action = Some( @@ -133,11 +151,25 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveLidarrBlock> for LibraryHandler<'a, ' .app .pop_and_push_navigation_stack(self.active_lidarr_block.into()); } + _ if matches_key!(update, key) => { + self + .app + .push_navigation_stack(ActiveLidarrBlock::UpdateAllArtistsPrompt.into()); + } _ if matches_key!(refresh, key) => { self.app.should_refresh = true; } _ => (), + }, + ActiveLidarrBlock::UpdateAllArtistsPrompt => { + if matches_key!(confirm, key) { + self.app.data.lidarr_data.prompt_confirm = true; + self.app.data.lidarr_data.prompt_confirm_action = Some(LidarrEvent::UpdateAllArtists); + + self.app.pop_navigation_stack(); + } } + _ => (), } } diff --git a/src/models/servarr_data/lidarr/lidarr_data.rs b/src/models/servarr_data/lidarr/lidarr_data.rs index 01b0bed..e2f6cb6 100644 --- a/src/models/servarr_data/lidarr/lidarr_data.rs +++ b/src/models/servarr_data/lidarr/lidarr_data.rs @@ -55,7 +55,7 @@ impl<'a> Default for LidarrData<'a> { quality_profile_map: BiMap::new(), root_folders: StatefulTable::default(), selected_block: BlockSelectionState::default(), - start_time: Utc::now(), + start_time: DateTime::default(), tags_map: BiMap::new(), version: String::new(), main_tabs: TabState::new(vec![TabRoute { @@ -112,19 +112,21 @@ pub enum ActiveLidarrBlock { DeleteArtistConfirmPrompt, DeleteArtistToggleDeleteFile, DeleteArtistToggleAddListExclusion, - SearchArtists, - SearchArtistsError, FilterArtists, FilterArtistsError, + SearchArtists, + SearchArtistsError, + UpdateAllArtistsPrompt, } -pub static LIBRARY_BLOCKS: [ActiveLidarrBlock; 6] = [ +pub static LIBRARY_BLOCKS: [ActiveLidarrBlock; 7] = [ ActiveLidarrBlock::Artists, ActiveLidarrBlock::ArtistsSortPrompt, - ActiveLidarrBlock::SearchArtists, - ActiveLidarrBlock::SearchArtistsError, ActiveLidarrBlock::FilterArtists, ActiveLidarrBlock::FilterArtistsError, + ActiveLidarrBlock::SearchArtists, + ActiveLidarrBlock::SearchArtistsError, + ActiveLidarrBlock::UpdateAllArtistsPrompt, ]; pub static DELETE_ARTIST_BLOCKS: [ActiveLidarrBlock; 4] = [ diff --git a/src/models/servarr_data/lidarr/lidarr_data_tests.rs b/src/models/servarr_data/lidarr/lidarr_data_tests.rs index 0b67bbf..5e032c5 100644 --- a/src/models/servarr_data/lidarr/lidarr_data_tests.rs +++ b/src/models/servarr_data/lidarr/lidarr_data_tests.rs @@ -1,11 +1,15 @@ #[cfg(test)] mod tests { - use pretty_assertions::assert_eq; - - use crate::models::{ - Route, - servarr_data::lidarr::lidarr_data::{ActiveLidarrBlock, LidarrData}, + use crate::app::lidarr::lidarr_context_clues::ARTISTS_CONTEXT_CLUES; + use crate::models::servarr_data::lidarr::lidarr_data::{ + DELETE_ARTIST_BLOCKS, DELETE_ARTIST_SELECTION_BLOCKS, }; + use crate::models::{ + BlockSelectionState, Route, + servarr_data::lidarr::lidarr_data::{ActiveLidarrBlock, LIBRARY_BLOCKS, LidarrData}, + }; + use chrono::{DateTime, Utc}; + use pretty_assertions::{assert_eq, assert_str_eq}; #[test] fn test_from_active_lidarr_block_to_route() { @@ -36,4 +40,77 @@ mod tests { assert!(!lidarr_data.delete_artist_files); assert!(!lidarr_data.add_import_list_exclusion); } + + #[test] + fn test_lidarr_data_default() { + let lidarr_data = LidarrData::default(); + + assert!(!lidarr_data.add_import_list_exclusion); + assert_is_empty!(lidarr_data.artists); + assert!(!lidarr_data.delete_artist_files); + assert_is_empty!(lidarr_data.disk_space_vec); + assert_is_empty!(lidarr_data.downloads); + assert_is_empty!(lidarr_data.metadata_profile_map); + assert!(!lidarr_data.prompt_confirm); + assert_none!(lidarr_data.prompt_confirm_action); + assert_is_empty!(lidarr_data.quality_profile_map); + assert_is_empty!(lidarr_data.root_folders); + assert_eq!(lidarr_data.selected_block, BlockSelectionState::default()); + assert_eq!(lidarr_data.start_time, >::default()); + assert_is_empty!(lidarr_data.tags_map); + assert_is_empty!(lidarr_data.version); + + assert_eq!(lidarr_data.main_tabs.tabs.len(), 1); + + assert_str_eq!(lidarr_data.main_tabs.tabs[0].title, "Library"); + assert_eq!( + lidarr_data.main_tabs.tabs[0].route, + ActiveLidarrBlock::Artists.into() + ); + assert_some_eq_x!( + &lidarr_data.main_tabs.tabs[0].contextual_help, + &ARTISTS_CONTEXT_CLUES + ); + assert_none!(lidarr_data.main_tabs.tabs[0].config); + } + + #[test] + fn test_library_blocks_contains_expected_blocks() { + assert_eq!(LIBRARY_BLOCKS.len(), 7); + assert!(LIBRARY_BLOCKS.contains(&ActiveLidarrBlock::Artists)); + assert!(LIBRARY_BLOCKS.contains(&ActiveLidarrBlock::ArtistsSortPrompt)); + assert!(LIBRARY_BLOCKS.contains(&ActiveLidarrBlock::SearchArtists)); + assert!(LIBRARY_BLOCKS.contains(&ActiveLidarrBlock::SearchArtistsError)); + assert!(LIBRARY_BLOCKS.contains(&ActiveLidarrBlock::FilterArtists)); + assert!(LIBRARY_BLOCKS.contains(&ActiveLidarrBlock::FilterArtistsError)); + assert!(LIBRARY_BLOCKS.contains(&ActiveLidarrBlock::UpdateAllArtistsPrompt)); + } + + #[test] + fn test_delete_artist_blocks_contents() { + assert_eq!(DELETE_ARTIST_BLOCKS.len(), 4); + assert!(DELETE_ARTIST_BLOCKS.contains(&ActiveLidarrBlock::DeleteArtistPrompt)); + assert!(DELETE_ARTIST_BLOCKS.contains(&ActiveLidarrBlock::DeleteArtistConfirmPrompt)); + assert!(DELETE_ARTIST_BLOCKS.contains(&ActiveLidarrBlock::DeleteArtistToggleDeleteFile)); + assert!(DELETE_ARTIST_BLOCKS.contains(&ActiveLidarrBlock::DeleteArtistToggleAddListExclusion)); + } + + #[test] + fn test_delete_artist_selection_blocks_ordering() { + let mut delete_artist_block_iter = DELETE_ARTIST_SELECTION_BLOCKS.iter(); + + assert_eq!( + delete_artist_block_iter.next().unwrap(), + &[ActiveLidarrBlock::DeleteArtistToggleDeleteFile] + ); + assert_eq!( + delete_artist_block_iter.next().unwrap(), + &[ActiveLidarrBlock::DeleteArtistToggleAddListExclusion] + ); + assert_eq!( + delete_artist_block_iter.next().unwrap(), + &[ActiveLidarrBlock::DeleteArtistConfirmPrompt] + ); + assert_none!(delete_artist_block_iter.next()); + } } diff --git a/src/models/servarr_data/sonarr/modals.rs b/src/models/servarr_data/sonarr/modals.rs index 026c974..d1d12d8 100644 --- a/src/models/servarr_data/sonarr/modals.rs +++ b/src/models/servarr_data/sonarr/modals.rs @@ -23,6 +23,7 @@ use crate::{ mod modals_tests; #[derive(Default)] +#[cfg_attr(test, derive(Debug))] pub struct AddSeriesModal { pub root_folder_list: StatefulList, pub monitor_list: StatefulList, @@ -130,6 +131,7 @@ impl From<&SonarrData<'_>> for EditIndexerModal { } #[derive(Default)] +#[cfg_attr(test, derive(Debug))] pub struct EditSeriesModal { pub series_type_list: StatefulList, pub quality_profile_list: StatefulList, @@ -260,6 +262,7 @@ impl Default for EpisodeDetailsModal { } } +#[cfg_attr(test, derive(Debug))] pub struct SeasonDetailsModal { pub episodes: StatefulTable, pub episode_files: StatefulTable, diff --git a/src/models/servarr_data/sonarr/sonarr_data_tests.rs b/src/models/servarr_data/sonarr/sonarr_data_tests.rs index 92b9275..2a70d40 100644 --- a/src/models/servarr_data/sonarr/sonarr_data_tests.rs +++ b/src/models/servarr_data/sonarr/sonarr_data_tests.rs @@ -82,39 +82,39 @@ 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_none!(sonarr_data.add_searched_series); + assert_none!(sonarr_data.add_series_search); + assert_none!(sonarr_data.add_series_modal); + assert_is_empty!(sonarr_data.blocklist); 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.edit_series_modal.is_none()); - assert!(sonarr_data.history.is_empty()); - assert!(sonarr_data.indexers.is_empty()); - assert!(sonarr_data.indexer_settings.is_none()); - assert!(sonarr_data.indexer_test_errors.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.log_details.is_empty()); + assert_is_empty!(sonarr_data.downloads); + assert_is_empty!(sonarr_data.disk_space_vec); + assert_none!(sonarr_data.edit_indexer_modal); + assert_none!(sonarr_data.edit_root_folder); + assert_none!(sonarr_data.edit_series_modal); + assert_is_empty!(sonarr_data.history); + assert_is_empty!(sonarr_data.indexers); + assert_none!(sonarr_data.indexer_settings); + assert_none!(sonarr_data.indexer_test_errors); + assert_none!(sonarr_data.indexer_test_all_results); + assert_is_empty!(sonarr_data.language_profiles_map); + assert_is_empty!(sonarr_data.logs); + assert_is_empty!(sonarr_data.log_details); assert!(!sonarr_data.prompt_confirm); - assert!(sonarr_data.prompt_confirm_action.is_none()); - 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_none!(sonarr_data.prompt_confirm_action); + assert_is_empty!(sonarr_data.quality_profile_map); + assert_is_empty!(sonarr_data.queued_events); + assert_is_empty!(sonarr_data.root_folders); + assert_is_empty!(sonarr_data.seasons); + assert_none!(sonarr_data.season_details_modal); assert_eq!(sonarr_data.selected_block, BlockSelectionState::default()); - assert!(sonarr_data.series.is_empty()); - assert!(sonarr_data.series_history.is_none()); + assert_is_empty!(sonarr_data.series); + assert_none!(sonarr_data.series_history); 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()); + assert_is_empty!(sonarr_data.tags_map); + assert_is_empty!(sonarr_data.tasks); + assert_is_empty!(sonarr_data.updates); + assert_is_empty!(sonarr_data.version); assert_eq!(sonarr_data.main_tabs.tabs.len(), 7); @@ -123,84 +123,77 @@ mod tests { sonarr_data.main_tabs.tabs[0].route, ActiveSonarrBlock::Series.into() ); - assert!(sonarr_data.main_tabs.tabs[0].contextual_help.is_some()); - assert_eq!( - sonarr_data.main_tabs.tabs[0].contextual_help.unwrap(), + assert_some_eq_x!( + &sonarr_data.main_tabs.tabs[0].contextual_help, &SERIES_CONTEXT_CLUES ); - assert_eq!(sonarr_data.main_tabs.tabs[0].config, None); + assert_none!(sonarr_data.main_tabs.tabs[0].config); assert_str_eq!(sonarr_data.main_tabs.tabs[1].title, "Downloads"); assert_eq!( sonarr_data.main_tabs.tabs[1].route, ActiveSonarrBlock::Downloads.into() ); - assert!(sonarr_data.main_tabs.tabs[1].contextual_help.is_some()); - assert_eq!( - sonarr_data.main_tabs.tabs[1].contextual_help.unwrap(), + assert_some_eq_x!( + &sonarr_data.main_tabs.tabs[1].contextual_help, &DOWNLOADS_CONTEXT_CLUES ); - assert_eq!(sonarr_data.main_tabs.tabs[1].config, None); + assert_none!(sonarr_data.main_tabs.tabs[1].config); assert_str_eq!(sonarr_data.main_tabs.tabs[2].title, "Blocklist"); assert_eq!( sonarr_data.main_tabs.tabs[2].route, ActiveSonarrBlock::Blocklist.into() ); - assert!(sonarr_data.main_tabs.tabs[2].contextual_help.is_some()); - assert_eq!( - sonarr_data.main_tabs.tabs[2].contextual_help.unwrap(), + assert_some_eq_x!( + &sonarr_data.main_tabs.tabs[2].contextual_help, &BLOCKLIST_CONTEXT_CLUES ); - assert_eq!(sonarr_data.main_tabs.tabs[2].config, None); + assert_none!(sonarr_data.main_tabs.tabs[2].config); assert_str_eq!(sonarr_data.main_tabs.tabs[3].title, "History"); assert_eq!( sonarr_data.main_tabs.tabs[3].route, ActiveSonarrBlock::History.into() ); - assert!(sonarr_data.main_tabs.tabs[3].contextual_help.is_some()); - assert_eq!( - sonarr_data.main_tabs.tabs[3].contextual_help.unwrap(), + assert_some_eq_x!( + &sonarr_data.main_tabs.tabs[3].contextual_help, &HISTORY_CONTEXT_CLUES ); - assert_eq!(sonarr_data.main_tabs.tabs[3].config, None); + assert_none!(sonarr_data.main_tabs.tabs[3].config); assert_str_eq!(sonarr_data.main_tabs.tabs[4].title, "Root Folders"); assert_eq!( sonarr_data.main_tabs.tabs[4].route, ActiveSonarrBlock::RootFolders.into() ); - assert!(sonarr_data.main_tabs.tabs[4].contextual_help.is_some()); - assert_eq!( - sonarr_data.main_tabs.tabs[4].contextual_help.unwrap(), + assert_some_eq_x!( + &sonarr_data.main_tabs.tabs[4].contextual_help, &ROOT_FOLDERS_CONTEXT_CLUES ); - assert_eq!(sonarr_data.main_tabs.tabs[4].config, None); + assert_none!(sonarr_data.main_tabs.tabs[4].config); assert_str_eq!(sonarr_data.main_tabs.tabs[5].title, "Indexers"); assert_eq!( sonarr_data.main_tabs.tabs[5].route, ActiveSonarrBlock::Indexers.into() ); - assert!(sonarr_data.main_tabs.tabs[5].contextual_help.is_some()); - assert_eq!( - sonarr_data.main_tabs.tabs[5].contextual_help.unwrap(), + assert_some_eq_x!( + &sonarr_data.main_tabs.tabs[5].contextual_help, &INDEXERS_CONTEXT_CLUES ); - assert_eq!(sonarr_data.main_tabs.tabs[5].config, None); + assert_none!(sonarr_data.main_tabs.tabs[5].config); assert_str_eq!(sonarr_data.main_tabs.tabs[6].title, "System"); assert_eq!( sonarr_data.main_tabs.tabs[6].route, ActiveSonarrBlock::System.into() ); - assert!(sonarr_data.main_tabs.tabs[6].contextual_help.is_some()); - assert_eq!( - sonarr_data.main_tabs.tabs[6].contextual_help.unwrap(), + assert_some_eq_x!( + &sonarr_data.main_tabs.tabs[6].contextual_help, &SYSTEM_CONTEXT_CLUES ); - assert_eq!(sonarr_data.main_tabs.tabs[6].config, None); + assert_none!(sonarr_data.main_tabs.tabs[6].config); assert_eq!(sonarr_data.series_info_tabs.tabs.len(), 2); @@ -209,36 +202,22 @@ mod tests { sonarr_data.series_info_tabs.tabs[0].route, ActiveSonarrBlock::SeriesDetails.into() ); - assert!( - sonarr_data.series_info_tabs.tabs[0] - .contextual_help - .is_some() - ); - assert_eq!( - sonarr_data.series_info_tabs.tabs[0] - .contextual_help - .unwrap(), + assert_some_eq_x!( + &sonarr_data.series_info_tabs.tabs[0].contextual_help, &SERIES_DETAILS_CONTEXT_CLUES ); - assert_eq!(sonarr_data.series_info_tabs.tabs[0].config, None); + assert_none!(sonarr_data.series_info_tabs.tabs[0].config); assert_str_eq!(sonarr_data.series_info_tabs.tabs[1].title, "History"); assert_eq!( sonarr_data.series_info_tabs.tabs[1].route, ActiveSonarrBlock::SeriesHistory.into() ); - assert!( - sonarr_data.series_info_tabs.tabs[1] - .contextual_help - .is_some() - ); - assert_eq!( - sonarr_data.series_info_tabs.tabs[1] - .contextual_help - .unwrap(), + assert_some_eq_x!( + &sonarr_data.series_info_tabs.tabs[1].contextual_help, &SERIES_HISTORY_CONTEXT_CLUES ); - assert_eq!(sonarr_data.series_info_tabs.tabs[1].config, None); + assert_none!(sonarr_data.series_info_tabs.tabs[1].config); } } diff --git a/src/network/lidarr_network/library/lidarr_library_network_tests.rs b/src/network/lidarr_network/library/lidarr_library_network_tests.rs index 5533dba..6cb6c3e 100644 --- a/src/network/lidarr_network/library/lidarr_library_network_tests.rs +++ b/src/network/lidarr_network/library/lidarr_library_network_tests.rs @@ -162,4 +162,26 @@ mod tests { get_mock.assert_async().await; put_mock.assert_async().await; } + + #[tokio::test] + async fn test_handle_update_all_artists_event() { + let (mock, app, _server) = MockServarrApi::post() + .with_request_body(json!({ + "name": "RefreshArtist" + })) + .returns(json!({})) + .build_for(LidarrEvent::UpdateAllArtists) + .await; + app.lock().await.server_tabs.set_index(2); + let mut network = test_network(&app); + + assert!( + network + .handle_lidarr_event(LidarrEvent::UpdateAllArtists) + .await + .is_ok() + ); + + mock.assert_async().await; + } } diff --git a/src/network/lidarr_network/library/mod.rs b/src/network/lidarr_network/library/mod.rs index 98987c6..9765cd1 100644 --- a/src/network/lidarr_network/library/mod.rs +++ b/src/network/lidarr_network/library/mod.rs @@ -5,6 +5,7 @@ use serde_json::{Value, json}; use crate::models::Route; use crate::models::lidarr_models::{Artist, DeleteArtistParams}; use crate::models::servarr_data::lidarr::lidarr_data::ActiveLidarrBlock; +use crate::models::servarr_models::CommandBody; use crate::network::lidarr_network::LidarrEvent; use crate::network::{Network, RequestMethod}; @@ -151,4 +152,20 @@ impl Network<'_, '_> { } } } + + pub(in crate::network::lidarr_network) async fn update_all_artists(&mut self) -> Result { + info!("Updating all artists"); + let event = LidarrEvent::UpdateAllArtists; + let body = CommandBody { + name: "RefreshArtist".to_owned(), + }; + + let request_props = self + .request_props_from(event, RequestMethod::Post, Some(body), None, None) + .await; + + self + .handle_request::(request_props, |_, _| ()) + .await + } } diff --git a/src/network/lidarr_network/lidarr_network_tests.rs b/src/network/lidarr_network/lidarr_network_tests.rs index 3a43da1..837885f 100644 --- a/src/network/lidarr_network/lidarr_network_tests.rs +++ b/src/network/lidarr_network/lidarr_network_tests.rs @@ -27,6 +27,11 @@ mod tests { assert_str_eq!(event.resource(), "/config/host"); } + #[rstest] + fn test_resource_command(#[values(LidarrEvent::UpdateAllArtists)] event: LidarrEvent) { + assert_str_eq!(event.resource(), "/command"); + } + #[rstest] #[case(LidarrEvent::GetDiskSpace, "/diskspace")] #[case(LidarrEvent::GetDownloads(500), "/queue")] diff --git a/src/network/lidarr_network/mod.rs b/src/network/lidarr_network/mod.rs index af16346..bdb5810 100644 --- a/src/network/lidarr_network/mod.rs +++ b/src/network/lidarr_network/mod.rs @@ -31,6 +31,7 @@ pub enum LidarrEvent { HealthCheck, ListArtists, ToggleArtistMonitoring(i64), + UpdateAllArtists, } impl NetworkResource for LidarrEvent { @@ -43,6 +44,7 @@ impl NetworkResource for LidarrEvent { LidarrEvent::GetDiskSpace => "/diskspace", LidarrEvent::GetDownloads(_) => "/queue", LidarrEvent::GetHostConfig | LidarrEvent::GetSecurityConfig => "/config/host", + LidarrEvent::UpdateAllArtists => "/command", LidarrEvent::GetMetadataProfiles => "/metadataprofile", LidarrEvent::GetQualityProfiles => "/qualityprofile", LidarrEvent::GetRootFolders => "/rootfolder", @@ -108,6 +110,7 @@ impl Network<'_, '_> { .toggle_artist_monitoring(artist_id) .await .map(LidarrSerdeable::from), + LidarrEvent::UpdateAllArtists => self.update_all_artists().await.map(LidarrSerdeable::from), } } diff --git a/src/ui/lidarr_ui/library/library_ui_tests.rs b/src/ui/lidarr_ui/library/library_ui_tests.rs index 56164aa..1be94f4 100644 --- a/src/ui/lidarr_ui/library/library_ui_tests.rs +++ b/src/ui/lidarr_ui/library/library_ui_tests.rs @@ -247,5 +247,18 @@ mod tests { insta::assert_snapshot!(output); } + + #[test] + fn test_library_ui_renders_update_all_artists_prompt() { + let mut app = App::test_default_fully_populated(); + app.push_navigation_stack(ActiveLidarrBlock::Artists.into()); + app.push_navigation_stack(ActiveLidarrBlock::UpdateAllArtistsPrompt.into()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + LibraryUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(output); + } } } diff --git a/src/ui/lidarr_ui/library/mod.rs b/src/ui/lidarr_ui/library/mod.rs index db3bb28..064181b 100644 --- a/src/ui/lidarr_ui/library/mod.rs +++ b/src/ui/lidarr_ui/library/mod.rs @@ -6,6 +6,10 @@ use ratatui::{ }; use crate::ui::widgets::managarr_table::ManagarrTable; +use crate::ui::widgets::{ + confirmation_prompt::ConfirmationPrompt, + popup::{Popup, Size}, +}; use crate::utils::convert_to_gb; use crate::{ app::App, @@ -42,8 +46,20 @@ impl DrawUi for LibraryUi { let route = app.get_current_route(); draw_library(f, app, area); - if DeleteArtistUi::accepts(route) { - DeleteArtistUi::draw(f, app, area); + match route { + _ if DeleteArtistUi::accepts(route) => DeleteArtistUi::draw(f, app, area), + Route::Lidarr(ActiveLidarrBlock::UpdateAllArtistsPrompt, _) => { + let confirmation_prompt = ConfirmationPrompt::new() + .title("Update All Artists") + .prompt("Do you want to update info and scan your disks for all of your artists?") + .yes_no_value(app.data.lidarr_data.prompt_confirm); + + f.render_widget( + Popup::new(confirmation_prompt).size(Size::MediumPrompt), + f.area(), + ); + } + _ => (), } } } diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_update_all_artists_prompt.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_update_all_artists_prompt.snap new file mode 100644 index 0000000..2f2678c --- /dev/null +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_update_all_artists_prompt.snap @@ -0,0 +1,38 @@ +--- +source: src/ui/lidarr_ui/library/library_ui_tests.rs +expression: output +--- +───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + Name ▼ Type Status Quality Profile Metadata Profile Albums Tracks Size Monitored Tags +=> Continuing 0 0.00 GB + + + + + + + + + + + + + + ╭────────────────── Update All Artists ───────────────────╮ + │ Do you want to update info and scan your disks for all of │ + │ your artists? │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │╭────────────────────────────╮╭───────────────────────────╮│ + ││ Yes ││ No ││ + │╰────────────────────────────╯╰───────────────────────────╯│ + ╰───────────────────────────────────────────────────────────╯ diff --git a/src/ui/sonarr_ui/library/library_ui_tests.rs b/src/ui/sonarr_ui/library/library_ui_tests.rs index 7d65fdc..0f73c61 100644 --- a/src/ui/sonarr_ui/library/library_ui_tests.rs +++ b/src/ui/sonarr_ui/library/library_ui_tests.rs @@ -382,5 +382,18 @@ mod tests { insta::assert_snapshot!(output); } + + #[test] + fn test_library_ui_renders_update_all_series_prompt() { + let mut app = App::test_default_fully_populated(); + app.push_navigation_stack(ActiveSonarrBlock::Series.into()); + app.push_navigation_stack(ActiveSonarrBlock::UpdateAllSeriesPrompt.into()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + LibraryUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(output); + } } } diff --git a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_update_all_series_prompt.snap b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_update_all_series_prompt.snap new file mode 100644 index 0000000..b43cea3 --- /dev/null +++ b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_update_all_series_prompt.snap @@ -0,0 +1,38 @@ +--- +source: src/ui/sonarr_ui/library/library_ui_tests.rs +expression: output +--- +───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + Title ▼ Year Network Status Rating Type Quality Profile Language Size Monitored Tags +=> Test 2022 HBO Continuin TV-MA Standard Bluray-1080p English 59.51 GB 🏷 + + + + + + + + + + + + + + ╭─────────────────── Update All Series ───────────────────╮ + │ Do you want to update info and scan your disks for all of │ + │ your series? │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │╭────────────────────────────╮╭───────────────────────────╮│ + ││ Yes ││ No ││ + │╰────────────────────────────╯╰───────────────────────────╯│ + ╰───────────────────────────────────────────────────────────╯ -- 2.52.0 From 3c1634d1e355a21b9b5fe44711c3c24ba168003c Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Wed, 7 Jan 2026 10:45:49 -0700 Subject: [PATCH 12/61] testing --- src/app/lidarr/lidarr_context_clues.rs | 3 +- src/app/lidarr/lidarr_context_clues_tests.rs | 4 + src/cli/lidarr/edit_command_handler.rs | 146 ++++++ src/cli/lidarr/edit_command_handler_tests.rs | 409 ++++++++++++++++ src/cli/lidarr/mod.rs | 12 + src/handlers/keybinding_handler.rs | 3 +- .../library/delete_artist_handler.rs | 3 +- .../library/edit_artist_handler.rs | 455 ++++++++++++++++++ .../library/edit_artist_handler_tests.rs | 215 +++++++++ .../library/library_handler_tests.rs | 13 +- src/handlers/lidarr_handlers/library/mod.rs | 37 +- .../lidarr_handlers/lidarr_handler_tests.rs | 38 +- src/handlers/lidarr_handlers/mod.rs | 10 +- src/handlers/radarr_handlers/blocklist/mod.rs | 3 +- .../collections/collection_details_handler.rs | 4 +- .../collections/edit_collection_handler.rs | 4 +- .../radarr_handlers/collections/mod.rs | 4 +- src/handlers/radarr_handlers/downloads/mod.rs | 3 +- .../indexers/edit_indexer_handler.rs | 3 +- .../indexers/edit_indexer_settings_handler.rs | 3 +- src/handlers/radarr_handlers/indexers/mod.rs | 4 +- .../indexers/test_all_indexers_handler.rs | 3 +- .../library/add_movie_handler.rs | 4 +- .../library/delete_movie_handler.rs | 3 +- .../library/edit_movie_handler.rs | 4 +- .../library/movie_details_handler.rs | 4 +- src/handlers/radarr_handlers/mod.rs | 3 +- .../radarr_handlers/root_folders/mod.rs | 4 +- src/handlers/radarr_handlers/system/mod.rs | 4 +- .../system/system_details_handler.rs | 4 +- src/handlers/sonarr_handlers/blocklist/mod.rs | 3 +- src/handlers/sonarr_handlers/downloads/mod.rs | 3 +- src/handlers/sonarr_handlers/history/mod.rs | 3 +- .../indexers/edit_indexer_handler.rs | 3 +- .../indexers/edit_indexer_settings_handler.rs | 3 +- src/handlers/sonarr_handlers/indexers/mod.rs | 4 +- .../indexers/test_all_indexers_handler.rs | 3 +- .../library/add_series_handler.rs | 4 +- .../library/delete_series_handler.rs | 3 +- .../library/edit_series_handler.rs | 4 +- .../library/episode_details_handler.rs | 3 +- src/handlers/sonarr_handlers/library/mod.rs | 3 +- .../library/season_details_handler.rs | 3 +- .../library/series_details_handler.rs | 4 +- src/handlers/sonarr_handlers/mod.rs | 4 +- .../sonarr_handlers/root_folders/mod.rs | 4 +- src/handlers/sonarr_handlers/system/mod.rs | 4 +- .../system/system_details_handler.rs | 4 +- src/handlers/table_handler_tests.rs | 3 +- src/models/lidarr_models.rs | 17 + src/models/servarr_data/lidarr/lidarr_data.rs | 103 +++- .../servarr_data/lidarr/lidarr_data_tests.rs | 78 ++- src/models/servarr_data/lidarr/mod.rs | 1 + src/models/servarr_data/lidarr/modals.rs | 78 +++ .../servarr_data/lidarr/modals_tests.rs | 48 ++ .../servarr_data/radarr/radarr_data_tests.rs | 32 +- .../servarr_data/sonarr/sonarr_data_tests.rs | 47 +- src/network/lidarr_network/library/mod.rs | 117 ++++- .../lidarr_network_test_utils.rs | 132 +++++ src/network/lidarr_network/mod.rs | 72 ++- .../sonarr_network_test_utils.rs | 1 + src/ui/lidarr_ui/library/edit_artist_ui.rs | 222 +++++++++ .../lidarr_ui/library/edit_artist_ui_tests.rs | 22 + src/ui/lidarr_ui/library/library_ui_tests.rs | 4 +- src/ui/lidarr_ui/library/mod.rs | 7 +- 65 files changed, 2355 insertions(+), 100 deletions(-) create mode 100644 src/cli/lidarr/edit_command_handler.rs create mode 100644 src/cli/lidarr/edit_command_handler_tests.rs create mode 100644 src/handlers/lidarr_handlers/library/edit_artist_handler.rs create mode 100644 src/handlers/lidarr_handlers/library/edit_artist_handler_tests.rs create mode 100644 src/models/servarr_data/lidarr/modals.rs create mode 100644 src/models/servarr_data/lidarr/modals_tests.rs create mode 100644 src/network/lidarr_network/lidarr_network_test_utils.rs create mode 100644 src/ui/lidarr_ui/library/edit_artist_ui.rs create mode 100644 src/ui/lidarr_ui/library/edit_artist_ui_tests.rs diff --git a/src/app/lidarr/lidarr_context_clues.rs b/src/app/lidarr/lidarr_context_clues.rs index 7c696c2..b33782b 100644 --- a/src/app/lidarr/lidarr_context_clues.rs +++ b/src/app/lidarr/lidarr_context_clues.rs @@ -7,12 +7,13 @@ use crate::models::Route; #[path = "lidarr_context_clues_tests.rs"] mod lidarr_context_clues_tests; -pub static ARTISTS_CONTEXT_CLUES: [ContextClue; 8] = [ +pub static ARTISTS_CONTEXT_CLUES: [ContextClue; 9] = [ ( DEFAULT_KEYBINDINGS.toggle_monitoring, DEFAULT_KEYBINDINGS.toggle_monitoring.desc, ), (DEFAULT_KEYBINDINGS.sort, DEFAULT_KEYBINDINGS.sort.desc), + (DEFAULT_KEYBINDINGS.edit, DEFAULT_KEYBINDINGS.edit.desc), (DEFAULT_KEYBINDINGS.delete, DEFAULT_KEYBINDINGS.delete.desc), (DEFAULT_KEYBINDINGS.search, DEFAULT_KEYBINDINGS.search.desc), (DEFAULT_KEYBINDINGS.filter, DEFAULT_KEYBINDINGS.filter.desc), diff --git a/src/app/lidarr/lidarr_context_clues_tests.rs b/src/app/lidarr/lidarr_context_clues_tests.rs index b219954..5348871 100644 --- a/src/app/lidarr/lidarr_context_clues_tests.rs +++ b/src/app/lidarr/lidarr_context_clues_tests.rs @@ -24,6 +24,10 @@ mod tests { artists_context_clues_iter.next(), &(DEFAULT_KEYBINDINGS.sort, DEFAULT_KEYBINDINGS.sort.desc) ); + assert_some_eq_x!( + artists_context_clues_iter.next(), + &(DEFAULT_KEYBINDINGS.edit, DEFAULT_KEYBINDINGS.edit.desc) + ); assert_some_eq_x!( artists_context_clues_iter.next(), &(DEFAULT_KEYBINDINGS.delete, DEFAULT_KEYBINDINGS.delete.desc) diff --git a/src/cli/lidarr/edit_command_handler.rs b/src/cli/lidarr/edit_command_handler.rs new file mode 100644 index 0000000..2fc0bd5 --- /dev/null +++ b/src/cli/lidarr/edit_command_handler.rs @@ -0,0 +1,146 @@ +use std::sync::Arc; + +use anyhow::Result; +use clap::{ArgAction, ArgGroup, Subcommand}; +use tokio::sync::Mutex; + +use crate::{ + app::App, + cli::{CliCommandHandler, Command, mutex_flags_or_option}, + models::lidarr_models::{EditArtistParams, NewItemMonitorType}, + network::{NetworkTrait, lidarr_network::LidarrEvent}, +}; + +use super::LidarrCommand; + +#[cfg(test)] +#[path = "edit_command_handler_tests.rs"] +mod edit_command_handler_tests; + +#[derive(Debug, Clone, PartialEq, Eq, Subcommand)] +pub enum LidarrEditCommand { + #[command( + about = "Edit preferences for the specified artist", + group( + ArgGroup::new("edit_artist") + .args([ + "enable_monitoring", + "disable_monitoring", + "monitor_new_items", + "quality_profile_id", + "metadata_profile_id", + "root_folder_path", + "tag", + "clear_tags" + ]).required(true) + .multiple(true)) + )] + Artist { + #[arg( + long, + help = "The ID of the artist whose settings you want to edit", + required = true + )] + artist_id: i64, + #[arg( + long, + help = "Enable monitoring of this artist in Lidarr so Lidarr will automatically download releases from this artist if they are available", + conflicts_with = "disable_monitoring" + )] + enable_monitoring: bool, + #[arg( + long, + help = "Disable monitoring of this artist so Lidarr does not automatically download releases from this artist if they are available", + conflicts_with = "enable_monitoring" + )] + disable_monitoring: bool, + #[arg( + long, + help = "How Lidarr should monitor new albums from this artist", + value_enum + )] + monitor_new_items: Option, + #[arg(long, help = "The ID of the quality profile to use for this artist")] + quality_profile_id: Option, + #[arg(long, help = "The ID of the metadata profile to use for this artist")] + metadata_profile_id: Option, + #[arg( + long, + help = "The root folder path where all artist data and metadata should live" + )] + root_folder_path: Option, + #[arg( + long, + help = "Tag IDs to tag this artist with", + value_parser, + action = ArgAction::Append, + conflicts_with = "clear_tags" + )] + tag: Option>, + #[arg(long, help = "Clear all tags on this artist", conflicts_with = "tag")] + clear_tags: bool, + }, +} + +impl From for Command { + fn from(value: LidarrEditCommand) -> Self { + Command::Lidarr(LidarrCommand::Edit(value)) + } +} + +pub(super) struct LidarrEditCommandHandler<'a, 'b> { + _app: &'a Arc>>, + command: LidarrEditCommand, + network: &'a mut dyn NetworkTrait, +} + +impl<'a, 'b> CliCommandHandler<'a, 'b, LidarrEditCommand> for LidarrEditCommandHandler<'a, 'b> { + fn with( + _app: &'a Arc>>, + command: LidarrEditCommand, + network: &'a mut dyn NetworkTrait, + ) -> Self { + LidarrEditCommandHandler { + _app, + command, + network, + } + } + + async fn handle(self) -> Result { + let result = match self.command { + LidarrEditCommand::Artist { + artist_id, + enable_monitoring, + disable_monitoring, + monitor_new_items, + quality_profile_id, + metadata_profile_id, + root_folder_path, + tag, + clear_tags, + } => { + let monitored_value = mutex_flags_or_option(enable_monitoring, disable_monitoring); + let edit_artist_params = EditArtistParams { + artist_id, + monitored: monitored_value, + monitor_new_items, + quality_profile_id, + metadata_profile_id, + root_folder_path, + tags: tag, + tag_input_string: None, + clear_tags, + }; + + self + .network + .handle_network_event(LidarrEvent::EditArtist(edit_artist_params).into()) + .await?; + "Artist Updated".to_owned() + } + }; + + Ok(result) + } +} diff --git a/src/cli/lidarr/edit_command_handler_tests.rs b/src/cli/lidarr/edit_command_handler_tests.rs new file mode 100644 index 0000000..bed34f0 --- /dev/null +++ b/src/cli/lidarr/edit_command_handler_tests.rs @@ -0,0 +1,409 @@ +#[cfg(test)] +mod tests { + use crate::cli::{ + Command, + lidarr::{LidarrCommand, edit_command_handler::LidarrEditCommand}, + }; + + #[test] + fn test_lidarr_edit_command_from() { + let command = LidarrEditCommand::Artist { + artist_id: 1, + enable_monitoring: false, + disable_monitoring: false, + monitor_new_items: None, + quality_profile_id: None, + metadata_profile_id: None, + root_folder_path: None, + tag: None, + clear_tags: false, + }; + + let result = Command::from(command.clone()); + + assert_eq!(result, Command::Lidarr(LidarrCommand::Edit(command))); + } + + mod cli { + use crate::{Cli, models::lidarr_models::NewItemMonitorType}; + + use super::*; + use clap::{CommandFactory, Parser, error::ErrorKind}; + use pretty_assertions::assert_eq; + use rstest::rstest; + + #[test] + fn test_edit_artist_requires_arguments() { + let result = Cli::command().try_get_matches_from(["managarr", "lidarr", "edit", "artist"]); + + assert_err!(&result); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_edit_artist_with_artist_id_still_requires_arguments() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "lidarr", + "edit", + "artist", + "--artist-id", + "1", + ]); + + assert_err!(&result); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_edit_artist_monitoring_flags_conflict() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "lidarr", + "edit", + "artist", + "--artist-id", + "1", + "--enable-monitoring", + "--disable-monitoring", + ]); + + assert_err!(&result); + assert_eq!(result.unwrap_err().kind(), ErrorKind::ArgumentConflict); + } + + #[test] + fn test_edit_artist_tag_flags_conflict() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "lidarr", + "edit", + "artist", + "--artist-id", + "1", + "--tag", + "1", + "--clear-tags", + ]); + + assert_err!(&result); + assert_eq!(result.unwrap_err().kind(), ErrorKind::ArgumentConflict); + } + + #[rstest] + fn test_edit_artist_assert_argument_flags_require_args( + #[values( + "--monitor-new-items", + "--quality-profile-id", + "--metadata-profile-id", + "--root-folder-path", + "--tag" + )] + flag: &str, + ) { + let result = Cli::command().try_get_matches_from([ + "managarr", + "lidarr", + "edit", + "artist", + "--artist-id", + "1", + flag, + ]); + + assert_err!(&result); + assert_eq!(result.unwrap_err().kind(), ErrorKind::InvalidValue); + } + + #[test] + fn test_edit_artist_monitor_new_items_validation() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "lidarr", + "edit", + "artist", + "--artist-id", + "1", + "--monitor-new-items", + "test", + ]); + + assert_err!(&result); + assert_eq!(result.unwrap_err().kind(), ErrorKind::InvalidValue); + } + + #[test] + fn test_edit_artist_only_requires_at_least_one_argument_plus_artist_id() { + let expected_args = LidarrEditCommand::Artist { + artist_id: 1, + enable_monitoring: false, + disable_monitoring: false, + monitor_new_items: None, + quality_profile_id: None, + metadata_profile_id: None, + root_folder_path: Some("/nfs/test".to_owned()), + tag: None, + clear_tags: false, + }; + + let result = Cli::try_parse_from([ + "managarr", + "lidarr", + "edit", + "artist", + "--artist-id", + "1", + "--root-folder-path", + "/nfs/test", + ]); + + assert_ok!(&result); + + let Some(Command::Lidarr(LidarrCommand::Edit(edit_command))) = result.unwrap().command else { + panic!("Unexpected command type"); + }; + assert_eq!(edit_command, expected_args); + } + + #[test] + fn test_edit_artist_tag_argument_is_repeatable() { + let expected_args = LidarrEditCommand::Artist { + artist_id: 1, + enable_monitoring: false, + disable_monitoring: false, + monitor_new_items: None, + quality_profile_id: None, + metadata_profile_id: None, + root_folder_path: None, + tag: Some(vec![1, 2]), + clear_tags: false, + }; + + let result = Cli::try_parse_from([ + "managarr", + "lidarr", + "edit", + "artist", + "--artist-id", + "1", + "--tag", + "1", + "--tag", + "2", + ]); + + assert_ok!(&result); + + let Some(Command::Lidarr(LidarrCommand::Edit(edit_command))) = result.unwrap().command else { + panic!("Unexpected command type"); + }; + assert_eq!(edit_command, expected_args); + } + + #[test] + fn test_edit_artist_all_arguments_defined() { + let expected_args = LidarrEditCommand::Artist { + artist_id: 1, + enable_monitoring: true, + disable_monitoring: false, + monitor_new_items: Some(NewItemMonitorType::New), + quality_profile_id: Some(1), + metadata_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", + "lidarr", + "edit", + "artist", + "--artist-id", + "1", + "--enable-monitoring", + "--monitor-new-items", + "new", + "--quality-profile-id", + "1", + "--metadata-profile-id", + "1", + "--root-folder-path", + "/nfs/test", + "--tag", + "1", + "--tag", + "2", + ]); + + assert_ok!(&result); + + let Some(Command::Lidarr(LidarrCommand::Edit(edit_command))) = result.unwrap().command else { + panic!("Unexpected command type"); + }; + 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::{ + CliCommandHandler, + lidarr::edit_command_handler::{LidarrEditCommand, LidarrEditCommandHandler}, + }, + models::{ + Serdeable, + lidarr_models::{EditArtistParams, LidarrSerdeable, NewItemMonitorType}, + }, + network::{MockNetworkTrait, NetworkEvent, lidarr_network::LidarrEvent}, + }; + + #[tokio::test] + async fn test_handle_edit_artist_command() { + let expected_edit_artist_params = EditArtistParams { + artist_id: 1, + monitored: Some(true), + monitor_new_items: Some(NewItemMonitorType::New), + quality_profile_id: Some(1), + metadata_profile_id: Some(1), + root_folder_path: Some("/nfs/test".to_owned()), + tags: Some(vec![1, 2]), + tag_input_string: None, + clear_tags: false, + }; + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::( + LidarrEvent::EditArtist(expected_edit_artist_params).into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Lidarr(LidarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::test_default())); + let edit_artist_command = LidarrEditCommand::Artist { + artist_id: 1, + enable_monitoring: true, + disable_monitoring: false, + monitor_new_items: Some(NewItemMonitorType::New), + quality_profile_id: Some(1), + metadata_profile_id: Some(1), + root_folder_path: Some("/nfs/test".to_owned()), + tag: Some(vec![1, 2]), + clear_tags: false, + }; + + let result = LidarrEditCommandHandler::with(&app_arc, edit_artist_command, &mut mock_network) + .handle() + .await; + + assert_ok!(&result); + } + + #[tokio::test] + async fn test_handle_edit_artist_command_handles_disable_monitoring_flag_properly() { + let expected_edit_artist_params = EditArtistParams { + artist_id: 1, + monitored: Some(false), + monitor_new_items: Some(NewItemMonitorType::None), + quality_profile_id: Some(1), + metadata_profile_id: Some(1), + root_folder_path: Some("/nfs/test".to_owned()), + tags: Some(vec![1, 2]), + tag_input_string: None, + clear_tags: false, + }; + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::( + LidarrEvent::EditArtist(expected_edit_artist_params).into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Lidarr(LidarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::test_default())); + let edit_artist_command = LidarrEditCommand::Artist { + artist_id: 1, + enable_monitoring: false, + disable_monitoring: true, + monitor_new_items: Some(NewItemMonitorType::None), + quality_profile_id: Some(1), + metadata_profile_id: Some(1), + root_folder_path: Some("/nfs/test".to_owned()), + tag: Some(vec![1, 2]), + clear_tags: false, + }; + + let result = LidarrEditCommandHandler::with(&app_arc, edit_artist_command, &mut mock_network) + .handle() + .await; + + assert_ok!(&result); + } + + #[tokio::test] + async fn test_handle_edit_artist_command_no_monitoring_boolean_flags_returns_none_value() { + let expected_edit_artist_params = EditArtistParams { + artist_id: 1, + monitored: None, + monitor_new_items: Some(NewItemMonitorType::All), + quality_profile_id: Some(1), + metadata_profile_id: Some(1), + root_folder_path: Some("/nfs/test".to_owned()), + tags: Some(vec![1, 2]), + tag_input_string: None, + clear_tags: false, + }; + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::( + LidarrEvent::EditArtist(expected_edit_artist_params).into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Lidarr(LidarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::test_default())); + let edit_artist_command = LidarrEditCommand::Artist { + artist_id: 1, + enable_monitoring: false, + disable_monitoring: false, + monitor_new_items: Some(NewItemMonitorType::All), + quality_profile_id: Some(1), + metadata_profile_id: Some(1), + root_folder_path: Some("/nfs/test".to_owned()), + tag: Some(vec![1, 2]), + clear_tags: false, + }; + + let result = LidarrEditCommandHandler::with(&app_arc, edit_artist_command, &mut mock_network) + .handle() + .await; + + assert_ok!(&result); + } + } +} diff --git a/src/cli/lidarr/mod.rs b/src/cli/lidarr/mod.rs index fcc37c8..a7a4fcc 100644 --- a/src/cli/lidarr/mod.rs +++ b/src/cli/lidarr/mod.rs @@ -3,6 +3,7 @@ use std::sync::Arc; use anyhow::Result; use clap::{Subcommand, arg}; use delete_command_handler::{LidarrDeleteCommand, LidarrDeleteCommandHandler}; +use edit_command_handler::{LidarrEditCommand, LidarrEditCommandHandler}; use get_command_handler::{LidarrGetCommand, LidarrGetCommandHandler}; use list_command_handler::{LidarrListCommand, LidarrListCommandHandler}; use refresh_command_handler::{LidarrRefreshCommand, LidarrRefreshCommandHandler}; @@ -14,6 +15,7 @@ use crate::{app::App, network::NetworkTrait}; use super::{CliCommandHandler, Command}; mod delete_command_handler; +mod edit_command_handler; mod get_command_handler; mod list_command_handler; mod refresh_command_handler; @@ -29,6 +31,11 @@ pub enum LidarrCommand { about = "Commands to delete resources from your Lidarr instance" )] Delete(LidarrDeleteCommand), + #[command( + subcommand, + about = "Commands to edit resources in your Lidarr instance" + )] + Edit(LidarrEditCommand), #[command( subcommand, about = "Commands to fetch details of the resources in your Lidarr instance" @@ -89,6 +96,11 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, LidarrCommand> for LidarrCliHandler<'a, ' .handle() .await? } + LidarrCommand::Edit(edit_command) => { + LidarrEditCommandHandler::with(self.app, edit_command, self.network) + .handle() + .await? + } LidarrCommand::Get(get_command) => { LidarrGetCommandHandler::with(self.app, get_command, self.network) .handle() diff --git a/src/handlers/keybinding_handler.rs b/src/handlers/keybinding_handler.rs index b9560a0..6965b1e 100644 --- a/src/handlers/keybinding_handler.rs +++ b/src/handlers/keybinding_handler.rs @@ -2,6 +2,7 @@ use crate::app::App; use crate::event::Key; use crate::handlers::KeyEventHandler; use crate::handlers::table_handler::{TableHandlingConfig, handle_table}; +use crate::models::Route; use crate::models::servarr_data::ActiveKeybindingBlock; #[cfg(test)] @@ -75,7 +76,7 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveKeybindingBlock> for KeybindingHandle self.app } - fn current_route(&self) -> crate::models::Route { + fn current_route(&self) -> Route { self.app.get_current_route() } } diff --git a/src/handlers/lidarr_handlers/library/delete_artist_handler.rs b/src/handlers/lidarr_handlers/library/delete_artist_handler.rs index 48c8251..31affd7 100644 --- a/src/handlers/lidarr_handlers/library/delete_artist_handler.rs +++ b/src/handlers/lidarr_handlers/library/delete_artist_handler.rs @@ -7,6 +7,7 @@ use crate::{ matches_key, models::servarr_data::lidarr::lidarr_data::{ActiveLidarrBlock, DELETE_ARTIST_BLOCKS}, }; +use crate::models::Route; #[cfg(test)] #[path = "delete_artist_handler_tests.rs"] @@ -143,7 +144,7 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveLidarrBlock> for DeleteArtistHandler< self.app } - fn current_route(&self) -> crate::models::Route { + fn current_route(&self) -> Route { self.app.get_current_route() } } diff --git a/src/handlers/lidarr_handlers/library/edit_artist_handler.rs b/src/handlers/lidarr_handlers/library/edit_artist_handler.rs new file mode 100644 index 0000000..3b62fae --- /dev/null +++ b/src/handlers/lidarr_handlers/library/edit_artist_handler.rs @@ -0,0 +1,455 @@ +use crate::app::App; +use crate::event::Key; +use crate::handlers::{KeyEventHandler, handle_prompt_toggle}; +use crate::models::{Route, Scrollable}; +use crate::models::lidarr_models::EditArtistParams; +use crate::models::servarr_data::lidarr::lidarr_data::{ActiveLidarrBlock, EDIT_ARTIST_BLOCKS}; +use crate::models::servarr_data::lidarr::modals::EditArtistModal; +use crate::network::lidarr_network::LidarrEvent; +use crate::{handle_text_box_keys, handle_text_box_left_right_keys, matches_key}; + +#[cfg(test)] +#[path = "edit_artist_handler_tests.rs"] +mod edit_artist_handler_tests; + +pub(in crate::handlers::lidarr_handlers) struct EditArtistHandler<'a, 'b> { + key: Key, + app: &'a mut App<'b>, + active_lidarr_block: ActiveLidarrBlock, + context: Option, +} + +impl EditArtistHandler<'_, '_> { + fn build_edit_artist_params(&mut self) -> EditArtistParams { + let edit_artist_modal = self + .app + .data + .lidarr_data + .edit_artist_modal + .take() + .expect("EditArtistModal is None"); + let artist_id = self.app.data.lidarr_data.artists.current_selection().id; + let tags = edit_artist_modal.tags.text; + + let EditArtistModal { + monitored, + path, + monitor_list, + quality_profile_list, + metadata_profile_list, + .. + } = edit_artist_modal; + let quality_profile = quality_profile_list.current_selection(); + let quality_profile_id = *self + .app + .data + .lidarr_data + .quality_profile_map + .iter() + .filter(|(_, value)| *value == quality_profile) + .map(|(key, _)| key) + .next() + .unwrap(); + let metadata_profile = metadata_profile_list.current_selection(); + let metadata_profile_id = *self + .app + .data + .lidarr_data + .metadata_profile_map + .iter() + .filter(|(_, value)| *value == metadata_profile) + .map(|(key, _)| key) + .next() + .unwrap(); + + EditArtistParams { + artist_id, + monitored, + monitor_new_items: Some(*monitor_list.current_selection()), + quality_profile_id: Some(quality_profile_id), + metadata_profile_id: Some(metadata_profile_id), + root_folder_path: Some(path.text), + tag_input_string: Some(tags), + ..EditArtistParams::default() + } + } +} + +impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveLidarrBlock> for EditArtistHandler<'a, 'b> { + fn accepts(active_block: ActiveLidarrBlock) -> bool { + EDIT_ARTIST_BLOCKS.contains(&active_block) + } + + fn ignore_special_keys(&self) -> bool { + self.app.ignore_special_keys_for_textbox_input + } + + fn new( + key: Key, + app: &'a mut App<'b>, + active_block: ActiveLidarrBlock, + context: Option, + ) -> EditArtistHandler<'a, 'b> { + EditArtistHandler { + key, + app, + active_lidarr_block: active_block, + context, + } + } + + fn get_key(&self) -> Key { + self.key + } + + fn is_ready(&self) -> bool { + !self.app.is_loading && self.app.data.lidarr_data.edit_artist_modal.is_some() + } + + fn handle_scroll_up(&mut self) { + match self.active_lidarr_block { + ActiveLidarrBlock::EditArtistSelectMonitorNewItems => self + .app + .data + .lidarr_data + .edit_artist_modal + .as_mut() + .unwrap() + .monitor_list + .scroll_up(), + ActiveLidarrBlock::EditArtistSelectQualityProfile => self + .app + .data + .lidarr_data + .edit_artist_modal + .as_mut() + .unwrap() + .quality_profile_list + .scroll_up(), + ActiveLidarrBlock::EditArtistSelectMetadataProfile => self + .app + .data + .lidarr_data + .edit_artist_modal + .as_mut() + .unwrap() + .metadata_profile_list + .scroll_up(), + ActiveLidarrBlock::EditArtistPrompt => self.app.data.lidarr_data.selected_block.up(), + _ => (), + } + } + + fn handle_scroll_down(&mut self) { + match self.active_lidarr_block { + ActiveLidarrBlock::EditArtistSelectMonitorNewItems => self + .app + .data + .lidarr_data + .edit_artist_modal + .as_mut() + .unwrap() + .monitor_list + .scroll_down(), + ActiveLidarrBlock::EditArtistSelectQualityProfile => self + .app + .data + .lidarr_data + .edit_artist_modal + .as_mut() + .unwrap() + .quality_profile_list + .scroll_down(), + ActiveLidarrBlock::EditArtistSelectMetadataProfile => self + .app + .data + .lidarr_data + .edit_artist_modal + .as_mut() + .unwrap() + .metadata_profile_list + .scroll_down(), + ActiveLidarrBlock::EditArtistPrompt => self.app.data.lidarr_data.selected_block.down(), + _ => (), + } + } + + fn handle_home(&mut self) { + match self.active_lidarr_block { + ActiveLidarrBlock::EditArtistSelectMonitorNewItems => self + .app + .data + .lidarr_data + .edit_artist_modal + .as_mut() + .unwrap() + .monitor_list + .scroll_to_top(), + ActiveLidarrBlock::EditArtistSelectQualityProfile => self + .app + .data + .lidarr_data + .edit_artist_modal + .as_mut() + .unwrap() + .quality_profile_list + .scroll_to_top(), + ActiveLidarrBlock::EditArtistSelectMetadataProfile => self + .app + .data + .lidarr_data + .edit_artist_modal + .as_mut() + .unwrap() + .metadata_profile_list + .scroll_to_top(), + ActiveLidarrBlock::EditArtistPathInput => self + .app + .data + .lidarr_data + .edit_artist_modal + .as_mut() + .unwrap() + .path + .scroll_home(), + ActiveLidarrBlock::EditArtistTagsInput => self + .app + .data + .lidarr_data + .edit_artist_modal + .as_mut() + .unwrap() + .tags + .scroll_home(), + _ => (), + } + } + + fn handle_end(&mut self) { + match self.active_lidarr_block { + ActiveLidarrBlock::EditArtistSelectMonitorNewItems => self + .app + .data + .lidarr_data + .edit_artist_modal + .as_mut() + .unwrap() + .monitor_list + .scroll_to_bottom(), + ActiveLidarrBlock::EditArtistSelectQualityProfile => self + .app + .data + .lidarr_data + .edit_artist_modal + .as_mut() + .unwrap() + .quality_profile_list + .scroll_to_bottom(), + ActiveLidarrBlock::EditArtistSelectMetadataProfile => self + .app + .data + .lidarr_data + .edit_artist_modal + .as_mut() + .unwrap() + .metadata_profile_list + .scroll_to_bottom(), + ActiveLidarrBlock::EditArtistPathInput => self + .app + .data + .lidarr_data + .edit_artist_modal + .as_mut() + .unwrap() + .path + .reset_offset(), + ActiveLidarrBlock::EditArtistTagsInput => self + .app + .data + .lidarr_data + .edit_artist_modal + .as_mut() + .unwrap() + .tags + .reset_offset(), + _ => (), + } + } + + fn handle_delete(&mut self) {} + + fn handle_left_right_action(&mut self) { + match self.active_lidarr_block { + ActiveLidarrBlock::EditArtistPrompt => handle_prompt_toggle(self.app, self.key), + ActiveLidarrBlock::EditArtistPathInput => { + handle_text_box_left_right_keys!( + self, + self.key, + self + .app + .data + .lidarr_data + .edit_artist_modal + .as_mut() + .unwrap() + .path + ) + } + ActiveLidarrBlock::EditArtistTagsInput => { + handle_text_box_left_right_keys!( + self, + self.key, + self + .app + .data + .lidarr_data + .edit_artist_modal + .as_mut() + .unwrap() + .tags + ) + } + _ => (), + } + } + + fn handle_submit(&mut self) { + match self.active_lidarr_block { + ActiveLidarrBlock::EditArtistPrompt => { + match self.app.data.lidarr_data.selected_block.get_active_block() { + ActiveLidarrBlock::EditArtistConfirmPrompt => { + if self.app.data.lidarr_data.prompt_confirm { + self.app.data.lidarr_data.prompt_confirm_action = + Some(LidarrEvent::EditArtist(self.build_edit_artist_params())); + self.app.should_refresh = true; + } + + self.app.pop_navigation_stack(); + } + ActiveLidarrBlock::EditArtistSelectMonitorNewItems + | ActiveLidarrBlock::EditArtistSelectQualityProfile + | ActiveLidarrBlock::EditArtistSelectMetadataProfile => self.app.push_navigation_stack( + ( + self.app.data.lidarr_data.selected_block.get_active_block(), + self.context, + ) + .into(), + ), + ActiveLidarrBlock::EditArtistPathInput | ActiveLidarrBlock::EditArtistTagsInput => { + self.app.push_navigation_stack( + ( + self.app.data.lidarr_data.selected_block.get_active_block(), + self.context, + ) + .into(), + ); + self.app.ignore_special_keys_for_textbox_input = true; + } + ActiveLidarrBlock::EditArtistToggleMonitored => { + self + .app + .data + .lidarr_data + .edit_artist_modal + .as_mut() + .unwrap() + .monitored = Some( + !self + .app + .data + .lidarr_data + .edit_artist_modal + .as_mut() + .unwrap() + .monitored + .unwrap_or_default(), + ) + } + _ => (), + } + } + ActiveLidarrBlock::EditArtistSelectMonitorNewItems + | ActiveLidarrBlock::EditArtistSelectQualityProfile + | ActiveLidarrBlock::EditArtistSelectMetadataProfile => self.app.pop_navigation_stack(), + ActiveLidarrBlock::EditArtistPathInput | ActiveLidarrBlock::EditArtistTagsInput => { + self.app.pop_navigation_stack(); + self.app.ignore_special_keys_for_textbox_input = false; + } + _ => (), + } + } + + fn handle_esc(&mut self) { + match self.active_lidarr_block { + ActiveLidarrBlock::EditArtistTagsInput | ActiveLidarrBlock::EditArtistPathInput => { + self.app.pop_navigation_stack(); + self.app.ignore_special_keys_for_textbox_input = false; + } + ActiveLidarrBlock::EditArtistPrompt => { + self.app.pop_navigation_stack(); + self.app.data.lidarr_data.edit_artist_modal = None; + self.app.data.lidarr_data.prompt_confirm = false; + } + ActiveLidarrBlock::EditArtistSelectMonitorNewItems + | ActiveLidarrBlock::EditArtistSelectQualityProfile + | ActiveLidarrBlock::EditArtistSelectMetadataProfile => self.app.pop_navigation_stack(), + _ => (), + } + } + + fn handle_char_key_event(&mut self) { + let key = self.key; + match self.active_lidarr_block { + ActiveLidarrBlock::EditArtistPathInput => { + handle_text_box_keys!( + self, + key, + self + .app + .data + .lidarr_data + .edit_artist_modal + .as_mut() + .unwrap() + .path + ) + } + ActiveLidarrBlock::EditArtistTagsInput => { + handle_text_box_keys!( + self, + key, + self + .app + .data + .lidarr_data + .edit_artist_modal + .as_mut() + .unwrap() + .tags + ) + } + ActiveLidarrBlock::EditArtistPrompt => { + if self.app.data.lidarr_data.selected_block.get_active_block() + == ActiveLidarrBlock::EditArtistConfirmPrompt + && matches_key!(confirm, key) + { + self.app.data.lidarr_data.prompt_confirm = true; + self.app.data.lidarr_data.prompt_confirm_action = + Some(LidarrEvent::EditArtist(self.build_edit_artist_params())); + self.app.should_refresh = true; + + self.app.pop_navigation_stack(); + } + } + _ => (), + } + } + + fn app_mut(&mut self) -> &mut App<'b> { + self.app + } + + fn current_route(&self) -> Route { + self.app.get_current_route() + } +} diff --git a/src/handlers/lidarr_handlers/library/edit_artist_handler_tests.rs b/src/handlers/lidarr_handlers/library/edit_artist_handler_tests.rs new file mode 100644 index 0000000..1f1ed9b --- /dev/null +++ b/src/handlers/lidarr_handlers/library/edit_artist_handler_tests.rs @@ -0,0 +1,215 @@ +#[cfg(test)] +mod tests { + use pretty_assertions::assert_eq; + use strum::IntoEnumIterator; + + use crate::app::App; + use crate::app::key_binding::DEFAULT_KEYBINDINGS; + use crate::event::Key; + use crate::handlers::KeyEventHandler; + use crate::handlers::lidarr_handlers::library::edit_artist_handler::EditArtistHandler; + use crate::models::servarr_data::lidarr::lidarr_data::{ActiveLidarrBlock, EDIT_ARTIST_BLOCKS}; + use crate::models::servarr_data::lidarr::modals::EditArtistModal; + + mod test_handle_scroll_up_and_down { + use pretty_assertions::assert_eq; + use rstest::rstest; + + use crate::models::BlockSelectionState; + use crate::models::servarr_data::lidarr::lidarr_data::EDIT_ARTIST_SELECTION_BLOCKS; + + use super::*; + + #[rstest] + fn test_edit_artist_prompt_scroll(#[values(Key::Up, Key::Down)] key: Key) { + let mut app = App::test_default(); + app.data.lidarr_data.edit_artist_modal = Some(EditArtistModal::default()); + app.data.lidarr_data.selected_block = BlockSelectionState::new(EDIT_ARTIST_SELECTION_BLOCKS); + app.data.lidarr_data.selected_block.down(); + + EditArtistHandler::new(key, &mut app, ActiveLidarrBlock::EditArtistPrompt, None).handle(); + + if key == Key::Up { + assert_eq!( + app.data.lidarr_data.selected_block.get_active_block(), + ActiveLidarrBlock::EditArtistToggleMonitored + ); + } else { + assert_eq!( + app.data.lidarr_data.selected_block.get_active_block(), + ActiveLidarrBlock::EditArtistSelectQualityProfile + ); + } + } + + #[rstest] + fn test_edit_artist_prompt_scroll_no_op_when_not_ready(#[values(Key::Up, Key::Down)] key: Key) { + let mut app = App::test_default(); + app.is_loading = true; + app.data.lidarr_data.edit_artist_modal = Some(EditArtistModal::default()); + app.data.lidarr_data.selected_block = BlockSelectionState::new(EDIT_ARTIST_SELECTION_BLOCKS); + app.data.lidarr_data.selected_block.down(); + + EditArtistHandler::new(key, &mut app, ActiveLidarrBlock::EditArtistPrompt, None).handle(); + + assert_eq!( + app.data.lidarr_data.selected_block.get_active_block(), + ActiveLidarrBlock::EditArtistSelectMonitorNewItems + ); + } + } + + mod test_handle_left_right_action { + use rstest::rstest; + + use super::*; + + #[rstest] + fn test_left_right_prompt_toggle(#[values(Key::Left, Key::Right)] key: Key) { + let mut app = App::test_default(); + app.data.lidarr_data.edit_artist_modal = Some(EditArtistModal::default()); + app.push_navigation_stack(ActiveLidarrBlock::EditArtistPrompt.into()); + + EditArtistHandler::new(key, &mut app, ActiveLidarrBlock::EditArtistPrompt, None).handle(); + + assert!(app.data.lidarr_data.prompt_confirm); + + EditArtistHandler::new(key, &mut app, ActiveLidarrBlock::EditArtistPrompt, None).handle(); + + assert!(!app.data.lidarr_data.prompt_confirm); + } + } + + mod test_handle_submit { + use crate::models::BlockSelectionState; + use crate::models::servarr_data::lidarr::lidarr_data::EDIT_ARTIST_SELECTION_BLOCKS; + + use super::*; + use crate::assert_navigation_popped; + + const SUBMIT_KEY: Key = DEFAULT_KEYBINDINGS.submit.key; + + #[test] + fn test_edit_artist_prompt_prompt_decline_submit() { + let mut app = App::test_default(); + app.data.lidarr_data.edit_artist_modal = Some(EditArtistModal::default()); + app.push_navigation_stack(ActiveLidarrBlock::Artists.into()); + app.push_navigation_stack(ActiveLidarrBlock::EditArtistPrompt.into()); + app.data.lidarr_data.selected_block = BlockSelectionState::new(EDIT_ARTIST_SELECTION_BLOCKS); + // Navigate to the confirm prompt (last selection block) + for _ in 0..EDIT_ARTIST_SELECTION_BLOCKS.len() - 1 { + app.data.lidarr_data.selected_block.down(); + } + + EditArtistHandler::new( + SUBMIT_KEY, + &mut app, + ActiveLidarrBlock::EditArtistPrompt, + None, + ) + .handle(); + + assert!(app.data.lidarr_data.prompt_confirm_action.is_none()); + assert_navigation_popped!(&app, ActiveLidarrBlock::Artists.into()); + } + } + + mod test_handle_esc { + use super::*; + use crate::assert_navigation_popped; + + const ESC_KEY: Key = DEFAULT_KEYBINDINGS.esc.key; + + #[test] + fn test_edit_artist_prompt_esc() { + let mut app = App::test_default(); + app.data.lidarr_data.edit_artist_modal = Some(EditArtistModal::default()); + app.push_navigation_stack(ActiveLidarrBlock::Artists.into()); + app.push_navigation_stack(ActiveLidarrBlock::EditArtistPrompt.into()); + app.data.lidarr_data.prompt_confirm = true; + + EditArtistHandler::new(ESC_KEY, &mut app, ActiveLidarrBlock::EditArtistPrompt, None).handle(); + + assert_navigation_popped!(&app, ActiveLidarrBlock::Artists.into()); + assert!(app.data.lidarr_data.edit_artist_modal.is_none()); + assert!(!app.data.lidarr_data.prompt_confirm); + } + + #[test] + fn test_edit_artist_select_blocks_esc() { + let mut app = App::test_default(); + app.data.lidarr_data.edit_artist_modal = Some(EditArtistModal::default()); + app.push_navigation_stack(ActiveLidarrBlock::Artists.into()); + app.push_navigation_stack(ActiveLidarrBlock::EditArtistPrompt.into()); + app.push_navigation_stack(ActiveLidarrBlock::EditArtistSelectQualityProfile.into()); + + EditArtistHandler::new( + ESC_KEY, + &mut app, + ActiveLidarrBlock::EditArtistSelectQualityProfile, + None, + ) + .handle(); + + assert_navigation_popped!(&app, ActiveLidarrBlock::EditArtistPrompt.into()); + } + } + + #[test] + fn test_edit_artist_handler_accepts() { + let mut edit_artist_handler_blocks = Vec::new(); + for block in ActiveLidarrBlock::iter() { + if EditArtistHandler::accepts(block) { + edit_artist_handler_blocks.push(block); + } + } + + assert_eq!(edit_artist_handler_blocks, EDIT_ARTIST_BLOCKS.to_vec()); + } + + #[test] + fn test_edit_artist_handler_is_not_ready_when_loading() { + let mut app = App::test_default(); + app.is_loading = true; + app.data.lidarr_data.edit_artist_modal = Some(EditArtistModal::default()); + + let handler = EditArtistHandler::new( + Key::Esc, + &mut app, + ActiveLidarrBlock::EditArtistPrompt, + None, + ); + + assert!(!handler.is_ready()); + } + + #[test] + fn test_edit_artist_handler_is_not_ready_when_edit_artist_modal_is_none() { + let mut app = App::test_default(); + app.data.lidarr_data.edit_artist_modal = None; + + let handler = EditArtistHandler::new( + Key::Esc, + &mut app, + ActiveLidarrBlock::EditArtistPrompt, + None, + ); + + assert!(!handler.is_ready()); + } + + #[test] + fn test_edit_artist_handler_is_ready_when_not_loading_and_modal_is_some() { + let mut app = App::test_default(); + app.data.lidarr_data.edit_artist_modal = Some(EditArtistModal::default()); + + let handler = EditArtistHandler::new( + Key::Esc, + &mut app, + ActiveLidarrBlock::EditArtistPrompt, + None, + ); + + assert!(handler.is_ready()); + } +} diff --git a/src/handlers/lidarr_handlers/library/library_handler_tests.rs b/src/handlers/lidarr_handlers/library/library_handler_tests.rs index a987d04..4a7d94e 100644 --- a/src/handlers/lidarr_handlers/library/library_handler_tests.rs +++ b/src/handlers/lidarr_handlers/library/library_handler_tests.rs @@ -11,19 +11,24 @@ mod tests { use crate::handlers::KeyEventHandler; use crate::handlers::lidarr_handlers::library::{LibraryHandler, artists_sorting_options}; use crate::models::lidarr_models::{Artist, ArtistStatistics, ArtistStatus}; - use crate::models::servarr_data::lidarr::lidarr_data::{ActiveLidarrBlock, LIBRARY_BLOCKS}; + use crate::models::servarr_data::lidarr::lidarr_data::{ActiveLidarrBlock, DELETE_ARTIST_BLOCKS, EDIT_ARTIST_BLOCKS, LIBRARY_BLOCKS}; use crate::network::lidarr_network::LidarrEvent; use crate::{assert_modal_absent, assert_navigation_popped, assert_navigation_pushed}; #[test] fn test_library_handler_accepts() { - for lidarr_block in ActiveLidarrBlock::iter() { - if LIBRARY_BLOCKS.contains(&lidarr_block) { + let mut library_handler_blocks = Vec::new(); + library_handler_blocks.extend(LIBRARY_BLOCKS); + library_handler_blocks.extend(DELETE_ARTIST_BLOCKS); + library_handler_blocks.extend(EDIT_ARTIST_BLOCKS); + + ActiveLidarrBlock::iter().for_each(|lidarr_block| { + if library_handler_blocks.contains(&lidarr_block) { assert!(LibraryHandler::accepts(lidarr_block)); } else { assert!(!LibraryHandler::accepts(lidarr_block)); } - } + }); } #[test] diff --git a/src/handlers/lidarr_handlers/library/mod.rs b/src/handlers/lidarr_handlers/library/mod.rs index d9e4482..114442d 100644 --- a/src/handlers/lidarr_handlers/library/mod.rs +++ b/src/handlers/lidarr_handlers/library/mod.rs @@ -7,8 +7,10 @@ use crate::{ BlockSelectionState, lidarr_models::Artist, servarr_data::lidarr::lidarr_data::{ - ActiveLidarrBlock, DELETE_ARTIST_SELECTION_BLOCKS, LIBRARY_BLOCKS, + ActiveLidarrBlock, DELETE_ARTIST_SELECTION_BLOCKS, EDIT_ARTIST_SELECTION_BLOCKS, + LIBRARY_BLOCKS, }, + servarr_data::lidarr::modals::EditArtistModal, stateful_table::SortOption, }, network::lidarr_network::LidarrEvent, @@ -18,8 +20,11 @@ use super::handle_change_tab_left_right_keys; use crate::handlers::table_handler::{TableHandlingConfig, handle_table}; mod delete_artist_handler; +mod edit_artist_handler; pub(in crate::handlers::lidarr_handlers) use delete_artist_handler::DeleteArtistHandler; +pub(in crate::handlers::lidarr_handlers) use edit_artist_handler::EditArtistHandler; +use crate::models::Route; #[cfg(test)] #[path = "library_handler_tests.rs"] @@ -29,7 +34,7 @@ pub(super) struct LibraryHandler<'a, 'b> { key: Key, app: &'a mut App<'b>, active_lidarr_block: ActiveLidarrBlock, - _context: Option, + context: Option, } impl LibraryHandler<'_, '_> { @@ -55,12 +60,23 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveLidarrBlock> for LibraryHandler<'a, ' |app| &mut app.data.lidarr_data.artists, artists_table_handling_config, ) { - self.handle_key_event(); + match self.active_lidarr_block { + _ if DeleteArtistHandler::accepts(self.active_lidarr_block) => { + DeleteArtistHandler::new(self.key, self.app, self.active_lidarr_block, self.context) + .handle(); + } + _ if EditArtistHandler::accepts(self.active_lidarr_block) => { + EditArtistHandler::new(self.key, self.app, self.active_lidarr_block, self.context).handle(); + } + _ => self.handle_key_event(), + } } } fn accepts(active_block: ActiveLidarrBlock) -> bool { - LIBRARY_BLOCKS.contains(&active_block) + DeleteArtistHandler::accepts(active_block) + || EditArtistHandler::accepts(active_block) + || LIBRARY_BLOCKS.contains(&active_block) } fn ignore_special_keys(&self) -> bool { @@ -77,7 +93,7 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveLidarrBlock> for LibraryHandler<'a, ' key, app, active_lidarr_block: active_block, - _context: context, + context, } } @@ -151,6 +167,15 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveLidarrBlock> for LibraryHandler<'a, ' .app .pop_and_push_navigation_stack(self.active_lidarr_block.into()); } + _ if matches_key!(edit, key) => { + self.app.data.lidarr_data.edit_artist_modal = + Some((&self.app.data.lidarr_data).into()); + self + .app + .push_navigation_stack(ActiveLidarrBlock::EditArtistPrompt.into()); + self.app.data.lidarr_data.selected_block = + BlockSelectionState::new(EDIT_ARTIST_SELECTION_BLOCKS); + } _ if matches_key!(update, key) => { self .app @@ -177,7 +202,7 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveLidarrBlock> for LibraryHandler<'a, ' self.app } - fn current_route(&self) -> crate::models::Route { + fn current_route(&self) -> Route { self.app.get_current_route() } } diff --git a/src/handlers/lidarr_handlers/lidarr_handler_tests.rs b/src/handlers/lidarr_handlers/lidarr_handler_tests.rs index 34af245..c180d35 100644 --- a/src/handlers/lidarr_handlers/lidarr_handler_tests.rs +++ b/src/handlers/lidarr_handlers/lidarr_handler_tests.rs @@ -1,11 +1,47 @@ #[cfg(test)] mod tests { + use rstest::rstest; use strum::IntoEnumIterator; - + use crate::app::App; + use crate::app::key_binding::DEFAULT_KEYBINDINGS; use crate::handlers::KeyEventHandler; use crate::handlers::lidarr_handlers::LidarrHandler; use crate::models::servarr_data::lidarr::lidarr_data::ActiveLidarrBlock; + #[rstest] + fn test_lidarr_handler_ignore_special_keys( + #[values(true, false)] ignore_special_keys_for_textbox_input: bool, + ) { + let mut app = App::test_default(); + app.ignore_special_keys_for_textbox_input = ignore_special_keys_for_textbox_input; + let handler = LidarrHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveLidarrBlock::default(), + None, + ); + + assert_eq!( + handler.ignore_special_keys(), + ignore_special_keys_for_textbox_input + ); + } + + #[test] + fn test_lidarr_handler_is_ready() { + let mut app = App::test_default(); + app.is_loading = true; + + let handler = LidarrHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveLidarrBlock::default(), + None, + ); + + assert!(handler.is_ready()); + } + #[test] fn test_lidarr_handler_accepts() { for lidarr_block in ActiveLidarrBlock::iter() { diff --git a/src/handlers/lidarr_handlers/mod.rs b/src/handlers/lidarr_handlers/mod.rs index 6932737..16ff15b 100644 --- a/src/handlers/lidarr_handlers/mod.rs +++ b/src/handlers/lidarr_handlers/mod.rs @@ -1,9 +1,9 @@ -use library::{DeleteArtistHandler, LibraryHandler}; +use library::LibraryHandler; use crate::{ app::App, event::Key, matches_key, models::servarr_data::lidarr::lidarr_data::ActiveLidarrBlock, }; - +use crate::models::Route; use super::KeyEventHandler; mod library; @@ -22,10 +22,6 @@ pub(super) struct LidarrHandler<'a, 'b> { impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveLidarrBlock> for LidarrHandler<'a, 'b> { fn handle(&mut self) { match self.active_lidarr_block { - _ if DeleteArtistHandler::accepts(self.active_lidarr_block) => { - DeleteArtistHandler::new(self.key, self.app, self.active_lidarr_block, self.context) - .handle(); - } _ if LibraryHandler::accepts(self.active_lidarr_block) => { LibraryHandler::new(self.key, self.app, self.active_lidarr_block, self.context).handle(); } @@ -85,7 +81,7 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveLidarrBlock> for LidarrHandler<'a, 'b self.app } - fn current_route(&self) -> crate::models::Route { + fn current_route(&self) -> Route { self.app.get_current_route() } } diff --git a/src/handlers/radarr_handlers/blocklist/mod.rs b/src/handlers/radarr_handlers/blocklist/mod.rs index 9cf43a0..44dcbbb 100644 --- a/src/handlers/radarr_handlers/blocklist/mod.rs +++ b/src/handlers/radarr_handlers/blocklist/mod.rs @@ -5,6 +5,7 @@ use crate::handlers::table_handler::{TableHandlingConfig, handle_table}; use crate::handlers::{KeyEventHandler, handle_clear_errors, handle_prompt_toggle}; use crate::matches_key; use crate::models::radarr_models::BlocklistItem; +use crate::models::Route; use crate::models::servarr_data::radarr::radarr_data::{ActiveRadarrBlock, BLOCKLIST_BLOCKS}; use crate::models::stateful_table::SortOption; use crate::network::radarr_network::RadarrEvent; @@ -178,7 +179,7 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for BlocklistHandler<'a, self.app } - fn current_route(&self) -> crate::models::Route { + fn current_route(&self) -> Route { self.app.get_current_route() } } diff --git a/src/handlers/radarr_handlers/collections/collection_details_handler.rs b/src/handlers/radarr_handlers/collections/collection_details_handler.rs index 2cb628b..5fb64a2 100644 --- a/src/handlers/radarr_handlers/collections/collection_details_handler.rs +++ b/src/handlers/radarr_handlers/collections/collection_details_handler.rs @@ -3,7 +3,7 @@ use crate::event::Key; use crate::handlers::KeyEventHandler; use crate::handlers::table_handler::{TableHandlingConfig, handle_table}; use crate::matches_key; -use crate::models::BlockSelectionState; +use crate::models::{BlockSelectionState, Route}; use crate::models::servarr_data::radarr::radarr_data::{ ADD_MOVIE_SELECTION_BLOCKS, ActiveRadarrBlock, COLLECTION_DETAILS_BLOCKS, EDIT_COLLECTION_SELECTION_BLOCKS, @@ -148,7 +148,7 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for CollectionDetailsHan self.app } - fn current_route(&self) -> crate::models::Route { + fn current_route(&self) -> Route { self.app.get_current_route() } } diff --git a/src/handlers/radarr_handlers/collections/edit_collection_handler.rs b/src/handlers/radarr_handlers/collections/edit_collection_handler.rs index dff7e63..1f38ce5 100644 --- a/src/handlers/radarr_handlers/collections/edit_collection_handler.rs +++ b/src/handlers/radarr_handlers/collections/edit_collection_handler.rs @@ -1,7 +1,7 @@ use crate::app::App; use crate::event::Key; use crate::handlers::{KeyEventHandler, handle_prompt_toggle}; -use crate::models::Scrollable; +use crate::models::{Route, Scrollable}; use crate::models::radarr_models::EditCollectionParams; use crate::models::servarr_data::radarr::modals::EditCollectionModal; use crate::models::servarr_data::radarr::radarr_data::{ActiveRadarrBlock, EDIT_COLLECTION_BLOCKS}; @@ -376,7 +376,7 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for EditCollectionHandle self.app } - fn current_route(&self) -> crate::models::Route { + fn current_route(&self) -> Route { self.app.get_current_route() } } diff --git a/src/handlers/radarr_handlers/collections/mod.rs b/src/handlers/radarr_handlers/collections/mod.rs index 69e22d1..bcc2370 100644 --- a/src/handlers/radarr_handlers/collections/mod.rs +++ b/src/handlers/radarr_handlers/collections/mod.rs @@ -6,7 +6,7 @@ use crate::handlers::radarr_handlers::handle_change_tab_left_right_keys; use crate::handlers::table_handler::{TableHandlingConfig, handle_table}; use crate::handlers::{KeyEventHandler, handle_clear_errors, handle_prompt_toggle}; use crate::matches_key; -use crate::models::BlockSelectionState; +use crate::models::{BlockSelectionState, Route}; use crate::models::radarr_models::Collection; use crate::models::servarr_data::radarr::radarr_data::{ ActiveRadarrBlock, COLLECTIONS_BLOCKS, EDIT_COLLECTION_SELECTION_BLOCKS, @@ -179,7 +179,7 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for CollectionsHandler<' self.app } - fn current_route(&self) -> crate::models::Route { + fn current_route(&self) -> Route { self.app.get_current_route() } } diff --git a/src/handlers/radarr_handlers/downloads/mod.rs b/src/handlers/radarr_handlers/downloads/mod.rs index 3a449d1..53899a5 100644 --- a/src/handlers/radarr_handlers/downloads/mod.rs +++ b/src/handlers/radarr_handlers/downloads/mod.rs @@ -4,6 +4,7 @@ use crate::handlers::radarr_handlers::handle_change_tab_left_right_keys; use crate::handlers::table_handler::{TableHandlingConfig, handle_table}; use crate::handlers::{KeyEventHandler, handle_clear_errors, handle_prompt_toggle}; use crate::matches_key; +use crate::models::Route; use crate::models::servarr_data::radarr::radarr_data::{ActiveRadarrBlock, DOWNLOADS_BLOCKS}; use crate::network::radarr_network::RadarrEvent; @@ -164,7 +165,7 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for DownloadsHandler<'a, self.app } - fn current_route(&self) -> crate::models::Route { + fn current_route(&self) -> Route { self.app.get_current_route() } } diff --git a/src/handlers/radarr_handlers/indexers/edit_indexer_handler.rs b/src/handlers/radarr_handlers/indexers/edit_indexer_handler.rs index 33a2383..1c224c3 100644 --- a/src/handlers/radarr_handlers/indexers/edit_indexer_handler.rs +++ b/src/handlers/radarr_handlers/indexers/edit_indexer_handler.rs @@ -8,6 +8,7 @@ use crate::network::radarr_network::RadarrEvent; use crate::{ handle_prompt_left_right_keys, handle_text_box_keys, handle_text_box_left_right_keys, matches_key, }; +use crate::models::Route; #[cfg(test)] #[path = "edit_indexer_handler_tests.rs"] @@ -527,7 +528,7 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for EditIndexerHandler<' self.app } - fn current_route(&self) -> crate::models::Route { + fn current_route(&self) -> Route { self.app.get_current_route() } } diff --git a/src/handlers/radarr_handlers/indexers/edit_indexer_settings_handler.rs b/src/handlers/radarr_handlers/indexers/edit_indexer_settings_handler.rs index f933187..f237f97 100644 --- a/src/handlers/radarr_handlers/indexers/edit_indexer_settings_handler.rs +++ b/src/handlers/radarr_handlers/indexers/edit_indexer_settings_handler.rs @@ -9,6 +9,7 @@ use crate::network::radarr_network::RadarrEvent; use crate::{ handle_prompt_left_right_keys, handle_text_box_keys, handle_text_box_left_right_keys, matches_key, }; +use crate::models::Route; #[cfg(test)] #[path = "edit_indexer_settings_handler_tests.rs"] @@ -293,7 +294,7 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for IndexerSettingsHandl self.app } - fn current_route(&self) -> crate::models::Route { + fn current_route(&self) -> Route { self.app.get_current_route() } } diff --git a/src/handlers/radarr_handlers/indexers/mod.rs b/src/handlers/radarr_handlers/indexers/mod.rs index 85f5adc..c37d9f6 100644 --- a/src/handlers/radarr_handlers/indexers/mod.rs +++ b/src/handlers/radarr_handlers/indexers/mod.rs @@ -7,7 +7,7 @@ use crate::handlers::radarr_handlers::indexers::test_all_indexers_handler::TestA use crate::handlers::table_handler::{TableHandlingConfig, handle_table}; use crate::handlers::{KeyEventHandler, handle_clear_errors, handle_prompt_toggle}; use crate::matches_key; -use crate::models::BlockSelectionState; +use crate::models::{BlockSelectionState, Route}; use crate::models::servarr_data::radarr::radarr_data::{ ActiveRadarrBlock, EDIT_INDEXER_NZB_SELECTION_BLOCKS, EDIT_INDEXER_TORRENT_SELECTION_BLOCKS, INDEXER_SETTINGS_SELECTION_BLOCKS, INDEXERS_BLOCKS, @@ -212,7 +212,7 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for IndexersHandler<'a, self.app } - fn current_route(&self) -> crate::models::Route { + fn current_route(&self) -> Route { self.app.get_current_route() } } diff --git a/src/handlers/radarr_handlers/indexers/test_all_indexers_handler.rs b/src/handlers/radarr_handlers/indexers/test_all_indexers_handler.rs index c8e26d4..a19cbf2 100644 --- a/src/handlers/radarr_handlers/indexers/test_all_indexers_handler.rs +++ b/src/handlers/radarr_handlers/indexers/test_all_indexers_handler.rs @@ -2,6 +2,7 @@ use crate::app::App; use crate::event::Key; use crate::handlers::KeyEventHandler; use crate::handlers::table_handler::{TableHandlingConfig, handle_table}; +use crate::models::Route; use crate::models::servarr_data::radarr::radarr_data::ActiveRadarrBlock; #[cfg(test)] @@ -101,7 +102,7 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for TestAllIndexersHandl self.app } - fn current_route(&self) -> crate::models::Route { + fn current_route(&self) -> Route { self.app.get_current_route() } } diff --git a/src/handlers/radarr_handlers/library/add_movie_handler.rs b/src/handlers/radarr_handlers/library/add_movie_handler.rs index e668304..555b93c 100644 --- a/src/handlers/radarr_handlers/library/add_movie_handler.rs +++ b/src/handlers/radarr_handlers/library/add_movie_handler.rs @@ -7,7 +7,7 @@ use crate::models::servarr_data::radarr::modals::AddMovieModal; use crate::models::servarr_data::radarr::radarr_data::{ ADD_MOVIE_BLOCKS, ADD_MOVIE_SELECTION_BLOCKS, ActiveRadarrBlock, }; -use crate::models::{BlockSelectionState, Scrollable}; +use crate::models::{BlockSelectionState, Route, Scrollable}; use crate::network::radarr_network::RadarrEvent; use crate::{App, Key, handle_text_box_keys, handle_text_box_left_right_keys, matches_key}; @@ -558,7 +558,7 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for AddMovieHandler<'a, self.app } - fn current_route(&self) -> crate::models::Route { + fn current_route(&self) -> Route { self.app.get_current_route() } } diff --git a/src/handlers/radarr_handlers/library/delete_movie_handler.rs b/src/handlers/radarr_handlers/library/delete_movie_handler.rs index 561489f..ab25e4c 100644 --- a/src/handlers/radarr_handlers/library/delete_movie_handler.rs +++ b/src/handlers/radarr_handlers/library/delete_movie_handler.rs @@ -3,6 +3,7 @@ use crate::event::Key; use crate::handlers::{KeyEventHandler, handle_prompt_toggle}; use crate::matches_key; use crate::models::radarr_models::DeleteMovieParams; +use crate::models::Route; use crate::models::servarr_data::radarr::radarr_data::{ActiveRadarrBlock, DELETE_MOVIE_BLOCKS}; use crate::network::radarr_network::RadarrEvent; @@ -141,7 +142,7 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for DeleteMovieHandler<' self.app } - fn current_route(&self) -> crate::models::Route { + fn current_route(&self) -> Route { self.app.get_current_route() } } diff --git a/src/handlers/radarr_handlers/library/edit_movie_handler.rs b/src/handlers/radarr_handlers/library/edit_movie_handler.rs index f463d0f..db5e4b7 100644 --- a/src/handlers/radarr_handlers/library/edit_movie_handler.rs +++ b/src/handlers/radarr_handlers/library/edit_movie_handler.rs @@ -1,7 +1,7 @@ use crate::app::App; use crate::event::Key; use crate::handlers::{KeyEventHandler, handle_prompt_toggle}; -use crate::models::Scrollable; +use crate::models::{Route, Scrollable}; use crate::models::radarr_models::EditMovieParams; use crate::models::servarr_data::radarr::modals::EditMovieModal; use crate::models::servarr_data::radarr::radarr_data::{ActiveRadarrBlock, EDIT_MOVIE_BLOCKS}; @@ -397,7 +397,7 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for EditMovieHandler<'a, self.app } - fn current_route(&self) -> crate::models::Route { + fn current_route(&self) -> Route { self.app.get_current_route() } } diff --git a/src/handlers/radarr_handlers/library/movie_details_handler.rs b/src/handlers/radarr_handlers/library/movie_details_handler.rs index 9877b79..bf321a1 100644 --- a/src/handlers/radarr_handlers/library/movie_details_handler.rs +++ b/src/handlers/radarr_handlers/library/movie_details_handler.rs @@ -11,7 +11,7 @@ use crate::models::servarr_data::radarr::radarr_data::{ }; use crate::models::servarr_models::Language; use crate::models::stateful_table::SortOption; -use crate::models::{BlockSelectionState, Scrollable}; +use crate::models::{BlockSelectionState, Route, Scrollable}; use crate::network::radarr_network::RadarrEvent; #[cfg(test)] @@ -379,7 +379,7 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for MovieDetailsHandler< self.app } - fn current_route(&self) -> crate::models::Route { + fn current_route(&self) -> Route { self.app.get_current_route() } } diff --git a/src/handlers/radarr_handlers/mod.rs b/src/handlers/radarr_handlers/mod.rs index d8f8075..a785c41 100644 --- a/src/handlers/radarr_handlers/mod.rs +++ b/src/handlers/radarr_handlers/mod.rs @@ -8,6 +8,7 @@ use crate::handlers::radarr_handlers::root_folders::RootFoldersHandler; use crate::handlers::radarr_handlers::system::SystemHandler; use crate::models::servarr_data::radarr::radarr_data::ActiveRadarrBlock; use crate::{App, Key, matches_key}; +use crate::models::Route; mod blocklist; mod collections; @@ -112,7 +113,7 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for RadarrHandler<'a, 'b self.app } - fn current_route(&self) -> crate::models::Route { + fn current_route(&self) -> Route { self.app.get_current_route() } } diff --git a/src/handlers/radarr_handlers/root_folders/mod.rs b/src/handlers/radarr_handlers/root_folders/mod.rs index aa2a64f..27a3bd4 100644 --- a/src/handlers/radarr_handlers/root_folders/mod.rs +++ b/src/handlers/radarr_handlers/root_folders/mod.rs @@ -3,7 +3,7 @@ use crate::event::Key; use crate::handlers::radarr_handlers::handle_change_tab_left_right_keys; use crate::handlers::table_handler::{TableHandlingConfig, handle_table}; use crate::handlers::{KeyEventHandler, handle_clear_errors, handle_prompt_toggle}; -use crate::models::HorizontallyScrollableText; +use crate::models::{HorizontallyScrollableText, Route}; use crate::models::servarr_data::radarr::radarr_data::{ActiveRadarrBlock, ROOT_FOLDERS_BLOCKS}; use crate::models::servarr_models::AddRootFolderBody; use crate::network::radarr_network::RadarrEvent; @@ -231,7 +231,7 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for RootFoldersHandler<' self.app } - fn current_route(&self) -> crate::models::Route { + fn current_route(&self) -> Route { self.app.get_current_route() } } diff --git a/src/handlers/radarr_handlers/system/mod.rs b/src/handlers/radarr_handlers/system/mod.rs index 50201b0..bf3cc56 100644 --- a/src/handlers/radarr_handlers/system/mod.rs +++ b/src/handlers/radarr_handlers/system/mod.rs @@ -4,7 +4,7 @@ use crate::handlers::radarr_handlers::handle_change_tab_left_right_keys; use crate::handlers::radarr_handlers::system::system_details_handler::SystemDetailsHandler; use crate::handlers::{KeyEventHandler, handle_clear_errors}; use crate::matches_key; -use crate::models::Scrollable; +use crate::models::{Route, Scrollable}; use crate::models::servarr_data::radarr::radarr_data::ActiveRadarrBlock; mod system_details_handler; @@ -129,7 +129,7 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for SystemHandler<'a, 'b self.app } - fn current_route(&self) -> crate::models::Route { + fn current_route(&self) -> Route { self.app.get_current_route() } } diff --git a/src/handlers/radarr_handlers/system/system_details_handler.rs b/src/handlers/radarr_handlers/system/system_details_handler.rs index 7b13d4e..60db4c7 100644 --- a/src/handlers/radarr_handlers/system/system_details_handler.rs +++ b/src/handlers/radarr_handlers/system/system_details_handler.rs @@ -2,7 +2,7 @@ use crate::app::App; use crate::event::Key; use crate::handlers::{KeyEventHandler, handle_prompt_toggle}; use crate::matches_key; -use crate::models::Scrollable; +use crate::models::{Route, Scrollable}; use crate::models::radarr_models::RadarrTaskName; use crate::models::servarr_data::radarr::radarr_data::{ActiveRadarrBlock, SYSTEM_DETAILS_BLOCKS}; use crate::models::stateful_list::StatefulList; @@ -201,7 +201,7 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for SystemDetailsHandler self.app } - fn current_route(&self) -> crate::models::Route { + fn current_route(&self) -> Route { self.app.get_current_route() } } diff --git a/src/handlers/sonarr_handlers/blocklist/mod.rs b/src/handlers/sonarr_handlers/blocklist/mod.rs index 2989042..c09f829 100644 --- a/src/handlers/sonarr_handlers/blocklist/mod.rs +++ b/src/handlers/sonarr_handlers/blocklist/mod.rs @@ -4,6 +4,7 @@ use crate::handlers::sonarr_handlers::handle_change_tab_left_right_keys; use crate::handlers::table_handler::{TableHandlingConfig, handle_table}; use crate::handlers::{KeyEventHandler, handle_clear_errors, handle_prompt_toggle}; use crate::matches_key; +use crate::models::Route; use crate::models::servarr_data::sonarr::sonarr_data::{ActiveSonarrBlock, BLOCKLIST_BLOCKS}; use crate::models::sonarr_models::BlocklistItem; use crate::models::stateful_table::SortOption; @@ -178,7 +179,7 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveSonarrBlock> for BlocklistHandler<'a, self.app } - fn current_route(&self) -> crate::models::Route { + fn current_route(&self) -> Route { self.app.get_current_route() } } diff --git a/src/handlers/sonarr_handlers/downloads/mod.rs b/src/handlers/sonarr_handlers/downloads/mod.rs index 7a9819a..f585388 100644 --- a/src/handlers/sonarr_handlers/downloads/mod.rs +++ b/src/handlers/sonarr_handlers/downloads/mod.rs @@ -4,6 +4,7 @@ use crate::handlers::sonarr_handlers::handle_change_tab_left_right_keys; use crate::handlers::table_handler::{TableHandlingConfig, handle_table}; use crate::handlers::{KeyEventHandler, handle_clear_errors, handle_prompt_toggle}; use crate::matches_key; +use crate::models::Route; use crate::models::servarr_data::sonarr::sonarr_data::{ActiveSonarrBlock, DOWNLOADS_BLOCKS}; use crate::network::sonarr_network::SonarrEvent; @@ -164,7 +165,7 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveSonarrBlock> for DownloadsHandler<'a, self.app } - fn current_route(&self) -> crate::models::Route { + fn current_route(&self) -> Route { self.app.get_current_route() } } diff --git a/src/handlers/sonarr_handlers/history/mod.rs b/src/handlers/sonarr_handlers/history/mod.rs index 049de25..827270f 100644 --- a/src/handlers/sonarr_handlers/history/mod.rs +++ b/src/handlers/sonarr_handlers/history/mod.rs @@ -4,6 +4,7 @@ use crate::handlers::sonarr_handlers::handle_change_tab_left_right_keys; use crate::handlers::table_handler::{TableHandlingConfig, handle_table}; use crate::handlers::{KeyEventHandler, handle_clear_errors}; use crate::matches_key; +use crate::models::Route; use crate::models::servarr_data::sonarr::sonarr_data::{ActiveSonarrBlock, HISTORY_BLOCKS}; use crate::models::servarr_models::Language; use crate::models::sonarr_models::SonarrHistoryItem; @@ -121,7 +122,7 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveSonarrBlock> for HistoryHandler<'a, ' self.app } - fn current_route(&self) -> crate::models::Route { + fn current_route(&self) -> Route { self.app.get_current_route() } } diff --git a/src/handlers/sonarr_handlers/indexers/edit_indexer_handler.rs b/src/handlers/sonarr_handlers/indexers/edit_indexer_handler.rs index c253008..c6613aa 100644 --- a/src/handlers/sonarr_handlers/indexers/edit_indexer_handler.rs +++ b/src/handlers/sonarr_handlers/indexers/edit_indexer_handler.rs @@ -8,6 +8,7 @@ use crate::network::sonarr_network::SonarrEvent; use crate::{ handle_prompt_left_right_keys, handle_text_box_keys, handle_text_box_left_right_keys, matches_key, }; +use crate::models::Route; #[cfg(test)] #[path = "edit_indexer_handler_tests.rs"] @@ -526,7 +527,7 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveSonarrBlock> for EditIndexerHandler<' self.app } - fn current_route(&self) -> crate::models::Route { + fn current_route(&self) -> Route { self.app.get_current_route() } } diff --git a/src/handlers/sonarr_handlers/indexers/edit_indexer_settings_handler.rs b/src/handlers/sonarr_handlers/indexers/edit_indexer_settings_handler.rs index 8da2449..22d0ea8 100644 --- a/src/handlers/sonarr_handlers/indexers/edit_indexer_settings_handler.rs +++ b/src/handlers/sonarr_handlers/indexers/edit_indexer_settings_handler.rs @@ -7,6 +7,7 @@ use crate::models::servarr_data::sonarr::sonarr_data::{ use crate::models::sonarr_models::IndexerSettings; use crate::network::sonarr_network::SonarrEvent; use crate::{handle_prompt_left_right_keys, matches_key}; +use crate::models::Route; #[cfg(test)] #[path = "edit_indexer_settings_handler_tests.rs"] @@ -202,7 +203,7 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveSonarrBlock> for IndexerSettingsHandl self.app } - fn current_route(&self) -> crate::models::Route { + fn current_route(&self) -> Route { self.app.get_current_route() } } diff --git a/src/handlers/sonarr_handlers/indexers/mod.rs b/src/handlers/sonarr_handlers/indexers/mod.rs index cc33890..3d3cd46 100644 --- a/src/handlers/sonarr_handlers/indexers/mod.rs +++ b/src/handlers/sonarr_handlers/indexers/mod.rs @@ -7,7 +7,7 @@ use crate::handlers::sonarr_handlers::indexers::test_all_indexers_handler::TestA use crate::handlers::table_handler::{TableHandlingConfig, handle_table}; use crate::handlers::{KeyEventHandler, handle_clear_errors, handle_prompt_toggle}; use crate::matches_key; -use crate::models::BlockSelectionState; +use crate::models::{BlockSelectionState, Route}; use crate::models::servarr_data::sonarr::sonarr_data::{ ActiveSonarrBlock, EDIT_INDEXER_NZB_SELECTION_BLOCKS, EDIT_INDEXER_TORRENT_SELECTION_BLOCKS, INDEXER_SETTINGS_SELECTION_BLOCKS, INDEXERS_BLOCKS, @@ -211,7 +211,7 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveSonarrBlock> for IndexersHandler<'a, self.app } - fn current_route(&self) -> crate::models::Route { + fn current_route(&self) -> Route { self.app.get_current_route() } } diff --git a/src/handlers/sonarr_handlers/indexers/test_all_indexers_handler.rs b/src/handlers/sonarr_handlers/indexers/test_all_indexers_handler.rs index d0e3d57..8e0c70a 100644 --- a/src/handlers/sonarr_handlers/indexers/test_all_indexers_handler.rs +++ b/src/handlers/sonarr_handlers/indexers/test_all_indexers_handler.rs @@ -2,6 +2,7 @@ use crate::app::App; use crate::event::Key; use crate::handlers::KeyEventHandler; use crate::handlers::table_handler::{TableHandlingConfig, handle_table}; +use crate::models::Route; use crate::models::servarr_data::sonarr::sonarr_data::ActiveSonarrBlock; #[cfg(test)] @@ -101,7 +102,7 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveSonarrBlock> for TestAllIndexersHandl self.app } - fn current_route(&self) -> crate::models::Route { + fn current_route(&self) -> Route { self.app.get_current_route() } } diff --git a/src/handlers/sonarr_handlers/library/add_series_handler.rs b/src/handlers/sonarr_handlers/library/add_series_handler.rs index e6f8162..80d8b81 100644 --- a/src/handlers/sonarr_handlers/library/add_series_handler.rs +++ b/src/handlers/sonarr_handlers/library/add_series_handler.rs @@ -5,7 +5,7 @@ use crate::models::servarr_data::sonarr::sonarr_data::{ ADD_SERIES_BLOCKS, ADD_SERIES_SELECTION_BLOCKS, ActiveSonarrBlock, }; use crate::models::sonarr_models::{AddSeriesBody, AddSeriesOptions, AddSeriesSearchResult}; -use crate::models::{BlockSelectionState, Scrollable}; +use crate::models::{BlockSelectionState, Route, Scrollable}; use crate::network::sonarr_network::SonarrEvent; use crate::{App, Key, handle_text_box_keys, handle_text_box_left_right_keys, matches_key}; @@ -625,7 +625,7 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveSonarrBlock> for AddSeriesHandler<'a, self.app } - fn current_route(&self) -> crate::models::Route { + fn current_route(&self) -> Route { self.app.get_current_route() } } diff --git a/src/handlers/sonarr_handlers/library/delete_series_handler.rs b/src/handlers/sonarr_handlers/library/delete_series_handler.rs index 97fde80..6b6dac5 100644 --- a/src/handlers/sonarr_handlers/library/delete_series_handler.rs +++ b/src/handlers/sonarr_handlers/library/delete_series_handler.rs @@ -7,6 +7,7 @@ use crate::{ matches_key, models::servarr_data::sonarr::sonarr_data::{ActiveSonarrBlock, DELETE_SERIES_BLOCKS}, }; +use crate::models::Route; #[cfg(test)] #[path = "delete_series_handler_tests.rs"] @@ -143,7 +144,7 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveSonarrBlock> for DeleteSeriesHandler< self.app } - fn current_route(&self) -> crate::models::Route { + fn current_route(&self) -> Route { self.app.get_current_route() } } diff --git a/src/handlers/sonarr_handlers/library/edit_series_handler.rs b/src/handlers/sonarr_handlers/library/edit_series_handler.rs index a57224e..7e872d8 100644 --- a/src/handlers/sonarr_handlers/library/edit_series_handler.rs +++ b/src/handlers/sonarr_handlers/library/edit_series_handler.rs @@ -1,7 +1,7 @@ use crate::app::App; use crate::event::Key; use crate::handlers::{KeyEventHandler, handle_prompt_toggle}; -use crate::models::Scrollable; +use crate::models::{Route, Scrollable}; use crate::models::servarr_data::sonarr::modals::EditSeriesModal; use crate::models::servarr_data::sonarr::sonarr_data::{ActiveSonarrBlock, EDIT_SERIES_BLOCKS}; use crate::models::sonarr_models::EditSeriesParams; @@ -471,7 +471,7 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveSonarrBlock> for EditSeriesHandler<'a self.app } - fn current_route(&self) -> crate::models::Route { + fn current_route(&self) -> Route { self.app.get_current_route() } } diff --git a/src/handlers/sonarr_handlers/library/episode_details_handler.rs b/src/handlers/sonarr_handlers/library/episode_details_handler.rs index c007778..107cc59 100644 --- a/src/handlers/sonarr_handlers/library/episode_details_handler.rs +++ b/src/handlers/sonarr_handlers/library/episode_details_handler.rs @@ -4,6 +4,7 @@ use crate::handlers::sonarr_handlers::library::season_details_handler::releases_ use crate::handlers::table_handler::{TableHandlingConfig, handle_table}; use crate::handlers::{KeyEventHandler, handle_prompt_toggle}; use crate::matches_key; +use crate::models::Route; use crate::models::servarr_data::sonarr::sonarr_data::{ActiveSonarrBlock, EPISODE_DETAILS_BLOCKS}; use crate::models::sonarr_models::{SonarrRelease, SonarrReleaseDownloadBody}; use crate::network::sonarr_network::SonarrEvent; @@ -370,7 +371,7 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveSonarrBlock> for EpisodeDetailsHandle self.app } - fn current_route(&self) -> crate::models::Route { + fn current_route(&self) -> Route { self.app.get_current_route() } } diff --git a/src/handlers/sonarr_handlers/library/mod.rs b/src/handlers/sonarr_handlers/library/mod.rs index dc60579..01934a4 100644 --- a/src/handlers/sonarr_handlers/library/mod.rs +++ b/src/handlers/sonarr_handlers/library/mod.rs @@ -25,6 +25,7 @@ use crate::handlers::sonarr_handlers::library::episode_details_handler::EpisodeD use crate::handlers::sonarr_handlers::library::season_details_handler::SeasonDetailsHandler; use crate::handlers::sonarr_handlers::library::series_details_handler::SeriesDetailsHandler; use crate::handlers::table_handler::{TableHandlingConfig, handle_table}; +use crate::models::Route; mod add_series_handler; mod delete_series_handler; @@ -245,7 +246,7 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveSonarrBlock> for LibraryHandler<'a, ' self.app } - fn current_route(&self) -> crate::models::Route { + fn current_route(&self) -> Route { self.app.get_current_route() } } diff --git a/src/handlers/sonarr_handlers/library/season_details_handler.rs b/src/handlers/sonarr_handlers/library/season_details_handler.rs index 193c3ff..2d4b2e1 100644 --- a/src/handlers/sonarr_handlers/library/season_details_handler.rs +++ b/src/handlers/sonarr_handlers/library/season_details_handler.rs @@ -12,6 +12,7 @@ use crate::models::sonarr_models::{ use crate::models::stateful_table::SortOption; use crate::network::sonarr_network::SonarrEvent; use serde_json::Number; +use crate::models::Route; #[cfg(test)] #[path = "season_details_handler_tests.rs"] @@ -458,7 +459,7 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveSonarrBlock> for SeasonDetailsHandler self.app } - fn current_route(&self) -> crate::models::Route { + fn current_route(&self) -> Route { self.app.get_current_route() } } diff --git a/src/handlers/sonarr_handlers/library/series_details_handler.rs b/src/handlers/sonarr_handlers/library/series_details_handler.rs index ee60e3e..478ef3f 100644 --- a/src/handlers/sonarr_handlers/library/series_details_handler.rs +++ b/src/handlers/sonarr_handlers/library/series_details_handler.rs @@ -4,7 +4,7 @@ use crate::handlers::sonarr_handlers::history::history_sorting_options; use crate::handlers::table_handler::{TableHandlingConfig, handle_table}; use crate::handlers::{KeyEventHandler, handle_prompt_toggle}; use crate::matches_key; -use crate::models::BlockSelectionState; +use crate::models::{BlockSelectionState, Route}; use crate::models::servarr_data::sonarr::sonarr_data::{ ActiveSonarrBlock, EDIT_SERIES_SELECTION_BLOCKS, SERIES_DETAILS_BLOCKS, }; @@ -342,7 +342,7 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveSonarrBlock> for SeriesDetailsHandler self.app } - fn current_route(&self) -> crate::models::Route { + fn current_route(&self) -> Route { self.app.get_current_route() } } diff --git a/src/handlers/sonarr_handlers/mod.rs b/src/handlers/sonarr_handlers/mod.rs index 033392e..e33eecb 100644 --- a/src/handlers/sonarr_handlers/mod.rs +++ b/src/handlers/sonarr_handlers/mod.rs @@ -9,7 +9,7 @@ use system::SystemHandler; use crate::{ app::App, event::Key, matches_key, models::servarr_data::sonarr::sonarr_data::ActiveSonarrBlock, }; - +use crate::models::Route; use super::KeyEventHandler; mod blocklist; @@ -115,7 +115,7 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveSonarrBlock> for SonarrHandler<'a, 'b self.app } - fn current_route(&self) -> crate::models::Route { + fn current_route(&self) -> Route { self.app.get_current_route() } } diff --git a/src/handlers/sonarr_handlers/root_folders/mod.rs b/src/handlers/sonarr_handlers/root_folders/mod.rs index 04da09c..9d6a3ee 100644 --- a/src/handlers/sonarr_handlers/root_folders/mod.rs +++ b/src/handlers/sonarr_handlers/root_folders/mod.rs @@ -3,7 +3,7 @@ use crate::event::Key; use crate::handlers::sonarr_handlers::handle_change_tab_left_right_keys; use crate::handlers::table_handler::{TableHandlingConfig, handle_table}; use crate::handlers::{KeyEventHandler, handle_clear_errors, handle_prompt_toggle}; -use crate::models::HorizontallyScrollableText; +use crate::models::{HorizontallyScrollableText, Route}; use crate::models::servarr_data::sonarr::sonarr_data::{ActiveSonarrBlock, ROOT_FOLDERS_BLOCKS}; use crate::models::servarr_models::AddRootFolderBody; use crate::network::sonarr_network::SonarrEvent; @@ -229,7 +229,7 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveSonarrBlock> for RootFoldersHandler<' self.app } - fn current_route(&self) -> crate::models::Route { + fn current_route(&self) -> Route { self.app.get_current_route() } } diff --git a/src/handlers/sonarr_handlers/system/mod.rs b/src/handlers/sonarr_handlers/system/mod.rs index 9c70ac4..34c0fff 100644 --- a/src/handlers/sonarr_handlers/system/mod.rs +++ b/src/handlers/sonarr_handlers/system/mod.rs @@ -4,7 +4,7 @@ use crate::handlers::sonarr_handlers::handle_change_tab_left_right_keys; use crate::handlers::sonarr_handlers::system::system_details_handler::SystemDetailsHandler; use crate::handlers::{KeyEventHandler, handle_clear_errors}; use crate::matches_key; -use crate::models::Scrollable; +use crate::models::{Route, Scrollable}; use crate::models::servarr_data::sonarr::sonarr_data::ActiveSonarrBlock; mod system_details_handler; @@ -129,7 +129,7 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveSonarrBlock> for SystemHandler<'a, 'b self.app } - fn current_route(&self) -> crate::models::Route { + fn current_route(&self) -> Route { self.app.get_current_route() } } diff --git a/src/handlers/sonarr_handlers/system/system_details_handler.rs b/src/handlers/sonarr_handlers/system/system_details_handler.rs index 57879e2..3b79e0d 100644 --- a/src/handlers/sonarr_handlers/system/system_details_handler.rs +++ b/src/handlers/sonarr_handlers/system/system_details_handler.rs @@ -2,7 +2,7 @@ use crate::app::App; use crate::event::Key; use crate::handlers::{KeyEventHandler, handle_prompt_toggle}; use crate::matches_key; -use crate::models::Scrollable; +use crate::models::{Route, Scrollable}; use crate::models::servarr_data::sonarr::sonarr_data::{ActiveSonarrBlock, SYSTEM_DETAILS_BLOCKS}; use crate::models::sonarr_models::SonarrTaskName; use crate::models::stateful_list::StatefulList; @@ -201,7 +201,7 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveSonarrBlock> for SystemDetailsHandler self.app } - fn current_route(&self) -> crate::models::Route { + fn current_route(&self) -> Route { self.app.get_current_route() } } diff --git a/src/handlers/table_handler_tests.rs b/src/handlers/table_handler_tests.rs index 4e5b9ab..fb0585d 100644 --- a/src/handlers/table_handler_tests.rs +++ b/src/handlers/table_handler_tests.rs @@ -14,6 +14,7 @@ mod tests { use crate::models::servarr_models::Language; use crate::models::stateful_table::SortOption; use rstest::rstest; + use crate::models::Route; struct TableHandlerUnit<'a, 'b> { key: Key, @@ -98,7 +99,7 @@ mod tests { self.app } - fn current_route(&self) -> crate::models::Route { + fn current_route(&self) -> Route { self.app.get_current_route() } } diff --git a/src/models/lidarr_models.rs b/src/models/lidarr_models.rs index 945c689..56278b0 100644 --- a/src/models/lidarr_models.rs +++ b/src/models/lidarr_models.rs @@ -118,6 +118,7 @@ impl From<(&i64, &String)> for MetadataProfile { Copy, Debug, EnumIter, + clap::ValueEnum, Display, EnumDisplayStyle, )] @@ -205,6 +206,21 @@ pub struct DeleteArtistParams { pub add_import_list_exclusion: bool, } +#[derive(Default, Clone, Serialize, Debug, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct EditArtistParams { + pub artist_id: i64, + pub monitored: Option, + pub monitor_new_items: Option, + pub quality_profile_id: Option, + pub metadata_profile_id: Option, + pub root_folder_path: Option, + pub tags: Option>, + #[serde(skip_serializing, skip_deserializing)] + pub tag_input_string: Option, + pub clear_tags: bool, +} + impl From for Serdeable { fn from(value: LidarrSerdeable) -> Serdeable { Serdeable::Lidarr(value) @@ -223,6 +239,7 @@ serde_enum_from!( RootFolders(Vec), SecurityConfig(SecurityConfig), SystemStatus(SystemStatus), + Tag(Tag), Tags(Vec), Value(Value), } diff --git a/src/models/servarr_data/lidarr/lidarr_data.rs b/src/models/servarr_data/lidarr/lidarr_data.rs index e2f6cb6..bc54f32 100644 --- a/src/models/servarr_data/lidarr/lidarr_data.rs +++ b/src/models/servarr_data/lidarr/lidarr_data.rs @@ -1,3 +1,5 @@ +use serde_json::Number; + use crate::app::lidarr::lidarr_context_clues::ARTISTS_CONTEXT_CLUES; use crate::models::{ BlockSelectionState, Route, TabRoute, TabState, @@ -8,9 +10,17 @@ use crate::models::{ use crate::network::lidarr_network::LidarrEvent; use bimap::BiMap; use chrono::{DateTime, Utc}; -use strum::EnumIter; +use strum::{EnumIter}; +use super::modals::EditArtistModal; #[cfg(test)] -use strum::{Display, EnumString}; +use { + strum::{Display, EnumString, IntoEnumIterator}, + crate::models::lidarr_models::NewItemMonitorType, + crate::models::stateful_table::SortOption, + crate::network::lidarr_network::lidarr_network_test_utils::test_utils::quality_profile_map, + crate::network::servarr_test_utils::diskspace, + crate::network::lidarr_network::lidarr_network_test_utils::test_utils::{download_record, metadata_profile, metadata_profile_map, quality_profile, root_folder, tags_map}, +}; #[cfg(test)] #[path = "lidarr_data_tests.rs"] @@ -22,6 +32,7 @@ pub struct LidarrData<'a> { pub delete_artist_files: bool, pub disk_space_vec: Vec, pub downloads: StatefulTable, + pub edit_artist_modal: Option, pub main_tabs: TabState, pub metadata_profile_map: BiMap, pub prompt_confirm: bool, @@ -39,6 +50,31 @@ impl LidarrData<'_> { self.delete_artist_files = false; self.add_import_list_exclusion = false; } + + pub fn tag_ids_to_display(&self, tag_ids: &[Number]) -> String { + tag_ids + .iter() + .filter_map(|id| { + let id = id.as_i64()?; + self.tags_map.get_by_left(&id).cloned() + }) + .collect::>() + .join(", ") + } + + pub fn sorted_quality_profile_names(&self) -> Vec { + let mut quality_profile_names: Vec = + self.quality_profile_map.right_values().cloned().collect(); + quality_profile_names.sort(); + quality_profile_names + } + + pub fn sorted_metadata_profile_names(&self) -> Vec { + let mut metadata_profile_names: Vec = + self.metadata_profile_map.right_values().cloned().collect(); + metadata_profile_names.sort(); + metadata_profile_names + } } impl<'a> Default for LidarrData<'a> { @@ -49,6 +85,7 @@ impl<'a> Default for LidarrData<'a> { delete_artist_files: false, disk_space_vec: Vec::new(), downloads: StatefulTable::default(), + edit_artist_modal: None, metadata_profile_map: BiMap::new(), prompt_confirm: false, prompt_confirm_action: None, @@ -71,11 +108,25 @@ impl<'a> Default for LidarrData<'a> { #[cfg(test)] impl LidarrData<'_> { pub fn test_default_fully_populated() -> Self { - use crate::models::lidarr_models::{Artist, DownloadRecord}; - use crate::models::servarr_models::{DiskSpace, RootFolder}; - use crate::models::stateful_table::SortOption; + let mut edit_artist_modal = EditArtistModal { + monitored: Some(true), + path: "/nfs/music".into(), + tags: "alex".into(), + ..EditArtistModal::default() + }; + edit_artist_modal.monitor_list.set_items(NewItemMonitorType::iter().collect()); + edit_artist_modal.quality_profile_list.set_items(vec![quality_profile().name]); + edit_artist_modal.metadata_profile_list.set_items(vec![metadata_profile().name]); - let mut lidarr_data = LidarrData::default(); + let mut lidarr_data = LidarrData { + delete_artist_files: true, + disk_space_vec: vec![diskspace()], + quality_profile_map: quality_profile_map(), + metadata_profile_map: metadata_profile_map(), + edit_artist_modal: Some(edit_artist_modal), + tags_map: tags_map(), + ..LidarrData::default() + }; lidarr_data.artists.set_items(vec![Artist::default()]); lidarr_data.artists.sorting(vec![SortOption { name: "Name", @@ -83,19 +134,12 @@ impl LidarrData<'_> { }]); lidarr_data.artists.search = Some("artist search".into()); lidarr_data.artists.filter = Some("artist filter".into()); - lidarr_data.quality_profile_map = BiMap::from_iter([(1i64, "Lossless".to_owned())]); - lidarr_data.metadata_profile_map = BiMap::from_iter([(1i64, "Standard".to_owned())]); - lidarr_data.tags_map = BiMap::from_iter([(1i64, "usenet".to_owned())]); - lidarr_data.disk_space_vec = vec![DiskSpace { - free_space: 50000000000, - total_space: 100000000000, - }]; lidarr_data .downloads - .set_items(vec![DownloadRecord::default()]); + .set_items(vec![download_record()]); lidarr_data .root_folders - .set_items(vec![RootFolder::default()]); + .set_items(vec![root_folder()]); lidarr_data.version = "1.0.0".to_owned(); lidarr_data @@ -112,6 +156,14 @@ pub enum ActiveLidarrBlock { DeleteArtistConfirmPrompt, DeleteArtistToggleDeleteFile, DeleteArtistToggleAddListExclusion, + EditArtistPrompt, + EditArtistConfirmPrompt, + EditArtistPathInput, + EditArtistSelectMetadataProfile, + EditArtistSelectMonitorNewItems, + EditArtistSelectQualityProfile, + EditArtistTagsInput, + EditArtistToggleMonitored, FilterArtists, FilterArtistsError, SearchArtists, @@ -142,6 +194,27 @@ pub const DELETE_ARTIST_SELECTION_BLOCKS: &[&[ActiveLidarrBlock]] = &[ &[ActiveLidarrBlock::DeleteArtistConfirmPrompt], ]; +pub static EDIT_ARTIST_BLOCKS: [ActiveLidarrBlock; 8] = [ + ActiveLidarrBlock::EditArtistPrompt, + ActiveLidarrBlock::EditArtistConfirmPrompt, + ActiveLidarrBlock::EditArtistPathInput, + ActiveLidarrBlock::EditArtistSelectMetadataProfile, + ActiveLidarrBlock::EditArtistSelectMonitorNewItems, + ActiveLidarrBlock::EditArtistSelectQualityProfile, + ActiveLidarrBlock::EditArtistTagsInput, + ActiveLidarrBlock::EditArtistToggleMonitored, +]; + +pub const EDIT_ARTIST_SELECTION_BLOCKS: &[&[ActiveLidarrBlock]] = &[ + &[ActiveLidarrBlock::EditArtistToggleMonitored], + &[ActiveLidarrBlock::EditArtistSelectMonitorNewItems], + &[ActiveLidarrBlock::EditArtistSelectQualityProfile], + &[ActiveLidarrBlock::EditArtistSelectMetadataProfile], + &[ActiveLidarrBlock::EditArtistPathInput], + &[ActiveLidarrBlock::EditArtistTagsInput], + &[ActiveLidarrBlock::EditArtistConfirmPrompt], +]; + impl From for Route { fn from(active_lidarr_block: ActiveLidarrBlock) -> Route { Route::Lidarr(active_lidarr_block, None) diff --git a/src/models/servarr_data/lidarr/lidarr_data_tests.rs b/src/models/servarr_data/lidarr/lidarr_data_tests.rs index 5e032c5..182bf48 100644 --- a/src/models/servarr_data/lidarr/lidarr_data_tests.rs +++ b/src/models/servarr_data/lidarr/lidarr_data_tests.rs @@ -1,15 +1,15 @@ #[cfg(test)] mod tests { + use bimap::BiMap; use crate::app::lidarr::lidarr_context_clues::ARTISTS_CONTEXT_CLUES; - use crate::models::servarr_data::lidarr::lidarr_data::{ - DELETE_ARTIST_BLOCKS, DELETE_ARTIST_SELECTION_BLOCKS, - }; + use crate::models::servarr_data::lidarr::lidarr_data::{DELETE_ARTIST_BLOCKS, DELETE_ARTIST_SELECTION_BLOCKS, EDIT_ARTIST_BLOCKS, EDIT_ARTIST_SELECTION_BLOCKS}; use crate::models::{ BlockSelectionState, Route, servarr_data::lidarr::lidarr_data::{ActiveLidarrBlock, LIBRARY_BLOCKS, LidarrData}, }; use chrono::{DateTime, Utc}; use pretty_assertions::{assert_eq, assert_str_eq}; + use serde_json::Number; #[test] fn test_from_active_lidarr_block_to_route() { @@ -41,6 +41,50 @@ mod tests { assert!(!lidarr_data.add_import_list_exclusion); } + #[test] + fn test_tag_ids_to_display() { + let mut tags_map = BiMap::new(); + tags_map.insert(3, "test 3".to_owned()); + tags_map.insert(2, "test 2".to_owned()); + tags_map.insert(1, "test 1".to_owned()); + let lidarr_data = LidarrData { + tags_map, + ..LidarrData::default() + }; + + assert_str_eq!(lidarr_data.tag_ids_to_display(&[Number::from(1), Number::from(2)]), "test 1, test 2"); + } + + #[test] + fn test_sorted_quality_profile_names() { + let mut quality_profile_map = BiMap::new(); + quality_profile_map.insert(3, "test 3".to_owned()); + quality_profile_map.insert(2, "test 2".to_owned()); + quality_profile_map.insert(1, "test 1".to_owned()); + let lidarr_data = LidarrData { + quality_profile_map, + ..LidarrData::default() + }; + let expected_quality_profile_vec = vec!["test 1".to_owned(), "test 2".to_owned(), "test 3".to_owned()]; + + assert_iter_eq!(lidarr_data.sorted_quality_profile_names(), expected_quality_profile_vec); + } + + #[test] + fn test_sorted_metadata_profile_names() { + let mut metadata_profile_map = BiMap::new(); + metadata_profile_map.insert(3, "test 3".to_owned()); + metadata_profile_map.insert(2, "test 2".to_owned()); + metadata_profile_map.insert(1, "test 1".to_owned()); + let lidarr_data = LidarrData { + metadata_profile_map, + ..LidarrData::default() + }; + let expected_metadata_profile_vec = vec!["test 1".to_owned(), "test 2".to_owned(), "test 3".to_owned()]; + + assert_iter_eq!(lidarr_data.sorted_metadata_profile_names(), expected_metadata_profile_vec); + } + #[test] fn test_lidarr_data_default() { let lidarr_data = LidarrData::default(); @@ -50,6 +94,7 @@ mod tests { assert!(!lidarr_data.delete_artist_files); assert_is_empty!(lidarr_data.disk_space_vec); assert_is_empty!(lidarr_data.downloads); + assert_none!(lidarr_data.edit_artist_modal); assert_is_empty!(lidarr_data.metadata_profile_map); assert!(!lidarr_data.prompt_confirm); assert_none!(lidarr_data.prompt_confirm_action); @@ -113,4 +158,31 @@ mod tests { ); assert_none!(delete_artist_block_iter.next()); } + + #[test] + fn test_edit_artist_blocks() { + assert_eq!(EDIT_ARTIST_BLOCKS.len(), 8); + assert!(EDIT_ARTIST_BLOCKS.contains(&ActiveLidarrBlock::EditArtistPrompt)); + assert!(EDIT_ARTIST_BLOCKS.contains(&ActiveLidarrBlock::EditArtistConfirmPrompt)); + assert!(EDIT_ARTIST_BLOCKS.contains(&ActiveLidarrBlock::EditArtistPathInput)); + assert!(EDIT_ARTIST_BLOCKS.contains(&ActiveLidarrBlock::EditArtistSelectMetadataProfile)); + assert!(EDIT_ARTIST_BLOCKS.contains(&ActiveLidarrBlock::EditArtistSelectMonitorNewItems)); + assert!(EDIT_ARTIST_BLOCKS.contains(&ActiveLidarrBlock::EditArtistSelectQualityProfile)); + assert!(EDIT_ARTIST_BLOCKS.contains(&ActiveLidarrBlock::EditArtistTagsInput)); + assert!(EDIT_ARTIST_BLOCKS.contains(&ActiveLidarrBlock::EditArtistToggleMonitored)); + } + + #[test] + fn test_edit_artist_selection_blocks_ordering() { + let mut edit_artist_block_iter = EDIT_ARTIST_SELECTION_BLOCKS.iter(); + + assert_eq!(edit_artist_block_iter.next().unwrap(), &[ActiveLidarrBlock::EditArtistToggleMonitored]); + assert_eq!(edit_artist_block_iter.next().unwrap(), &[ActiveLidarrBlock::EditArtistSelectMonitorNewItems]); + assert_eq!(edit_artist_block_iter.next().unwrap(), &[ActiveLidarrBlock::EditArtistSelectQualityProfile]); + assert_eq!(edit_artist_block_iter.next().unwrap(), &[ActiveLidarrBlock::EditArtistSelectMetadataProfile]); + assert_eq!(edit_artist_block_iter.next().unwrap(), &[ActiveLidarrBlock::EditArtistPathInput]); + assert_eq!(edit_artist_block_iter.next().unwrap(), &[ActiveLidarrBlock::EditArtistTagsInput]); + assert_eq!(edit_artist_block_iter.next().unwrap(), &[ActiveLidarrBlock::EditArtistConfirmPrompt]); + assert_none!(edit_artist_block_iter.next()); + } } diff --git a/src/models/servarr_data/lidarr/mod.rs b/src/models/servarr_data/lidarr/mod.rs index 81f6a27..2db4fdf 100644 --- a/src/models/servarr_data/lidarr/mod.rs +++ b/src/models/servarr_data/lidarr/mod.rs @@ -1 +1,2 @@ pub mod lidarr_data; +pub mod modals; diff --git a/src/models/servarr_data/lidarr/modals.rs b/src/models/servarr_data/lidarr/modals.rs new file mode 100644 index 0000000..b769d65 --- /dev/null +++ b/src/models/servarr_data/lidarr/modals.rs @@ -0,0 +1,78 @@ +use strum::IntoEnumIterator; + +use super::lidarr_data::LidarrData; +use crate::models::{ + HorizontallyScrollableText, lidarr_models::NewItemMonitorType, stateful_list::StatefulList, +}; + +#[cfg(test)] +#[path = "modals_tests.rs"] +mod modals_tests; + +#[derive(Default)] +#[cfg_attr(test, derive(Debug))] +pub struct EditArtistModal { + pub monitor_list: StatefulList, + pub quality_profile_list: StatefulList, + pub metadata_profile_list: StatefulList, + pub monitored: Option, + pub path: HorizontallyScrollableText, + pub tags: HorizontallyScrollableText, +} + +impl From<&LidarrData<'_>> for EditArtistModal { + fn from(lidarr_data: &LidarrData<'_>) -> EditArtistModal { + let mut edit_artist_modal = EditArtistModal::default(); + let artist = lidarr_data.artists.current_selection(); + + edit_artist_modal + .monitor_list + .set_items(Vec::from_iter(NewItemMonitorType::iter())); + edit_artist_modal.path = artist.path.clone().into(); + edit_artist_modal.tags = lidarr_data.tag_ids_to_display(&artist.tags).into(); + edit_artist_modal.monitored = Some(artist.monitored); + + let monitor_index = edit_artist_modal + .monitor_list + .items + .iter() + .position(|m| *m == artist.monitor_new_items); + edit_artist_modal.monitor_list.state.select(monitor_index); + + edit_artist_modal + .quality_profile_list + .set_items(lidarr_data.sorted_quality_profile_names()); + let quality_profile_name = lidarr_data + .quality_profile_map + .get_by_left(&artist.quality_profile_id) + .unwrap(); + let quality_profile_index = edit_artist_modal + .quality_profile_list + .items + .iter() + .position(|profile| profile == quality_profile_name); + edit_artist_modal + .quality_profile_list + .state + .select(quality_profile_index); + + edit_artist_modal + .metadata_profile_list + .set_items(lidarr_data.sorted_metadata_profile_names()); + let metadata_profile_name = lidarr_data + .metadata_profile_map + .get_by_left(&artist.metadata_profile_id) + .unwrap(); + let metadata_profile_index = edit_artist_modal + .metadata_profile_list + .items + .iter() + .position(|profile| profile == metadata_profile_name); + edit_artist_modal + .metadata_profile_list + .state + .select(metadata_profile_index); + + edit_artist_modal + } +} diff --git a/src/models/servarr_data/lidarr/modals_tests.rs b/src/models/servarr_data/lidarr/modals_tests.rs new file mode 100644 index 0000000..0889091 --- /dev/null +++ b/src/models/servarr_data/lidarr/modals_tests.rs @@ -0,0 +1,48 @@ +#[cfg(test)] +mod tests { + use bimap::BiMap; + use pretty_assertions::{assert_eq, assert_str_eq}; + + use crate::models::lidarr_models::{Artist, NewItemMonitorType}; + use crate::models::servarr_data::lidarr::lidarr_data::LidarrData; + use crate::models::servarr_data::lidarr::modals::EditArtistModal; + + #[test] + fn test_edit_artist_modal_from_lidarr_data() { + let mut lidarr_data = LidarrData { + quality_profile_map: BiMap::from_iter([(1i64, "HD - 1080p".to_owned()), (2i64, "Any".to_owned())]), + metadata_profile_map: BiMap::from_iter([(1i64, "Standard".to_owned()), (2i64, "None".to_owned())]), + tags_map: BiMap::from_iter([(1i64, "usenet".to_owned())]), + ..LidarrData::default() + }; + let artist = Artist { + id: 1, + monitored: true, + monitor_new_items: NewItemMonitorType::All, + quality_profile_id: 1, + metadata_profile_id: 1, + path: "/nfs/music/test_artist".to_owned(), + tags: vec![serde_json::Number::from(1)], + ..Artist::default() + }; + lidarr_data.artists.set_items(vec![artist]); + + let edit_artist_modal = EditArtistModal::from(&lidarr_data); + + assert_eq!(edit_artist_modal.monitored, Some(true)); + assert_eq!( + *edit_artist_modal.monitor_list.current_selection(), + NewItemMonitorType::All + ); + assert_str_eq!( + edit_artist_modal.quality_profile_list.current_selection(), + "HD - 1080p" + ); + assert_str_eq!( + edit_artist_modal.metadata_profile_list.current_selection(), + "Standard" + ); + assert_str_eq!(edit_artist_modal.path.text, "/nfs/music/test_artist"); + assert_str_eq!(edit_artist_modal.tags.text, "usenet"); + } +} diff --git a/src/models/servarr_data/radarr/radarr_data_tests.rs b/src/models/servarr_data/radarr/radarr_data_tests.rs index b56d232..15c8b6d 100644 --- a/src/models/servarr_data/radarr/radarr_data_tests.rs +++ b/src/models/servarr_data/radarr/radarr_data_tests.rs @@ -1,9 +1,10 @@ #[cfg(test)] mod tests { mod radarr_data_tests { + use bimap::BiMap; use chrono::{DateTime, Utc}; use pretty_assertions::{assert_eq, assert_str_eq}; - + use serde_json::Number; use crate::app::context_clues::{ BLOCKLIST_CONTEXT_CLUES, DOWNLOADS_CONTEXT_CLUES, INDEXERS_CONTEXT_CLUES, ROOT_FOLDERS_CONTEXT_CLUES, SYSTEM_CONTEXT_CLUES, @@ -61,6 +62,35 @@ mod tests { assert_movie_info_tabs_reset!(radarr_data); } + #[test] + fn test_tag_ids_to_display() { + let mut tags_map = BiMap::new(); + tags_map.insert(3, "test 3".to_owned()); + tags_map.insert(2, "test 2".to_owned()); + tags_map.insert(1, "test 1".to_owned()); + let radarr_data = RadarrData { + tags_map, + ..RadarrData::default() + }; + + assert_str_eq!(radarr_data.tag_ids_to_display(&[Number::from(1), Number::from(2)]), "test 1, test 2"); + } + + #[test] + fn test_sorted_quality_profile_names() { + let mut quality_profile_map = BiMap::new(); + quality_profile_map.insert(3, "test 3".to_owned()); + quality_profile_map.insert(2, "test 2".to_owned()); + quality_profile_map.insert(1, "test 1".to_owned()); + let radarr_data = RadarrData { + quality_profile_map, + ..RadarrData::default() + }; + let expected_quality_profile_vec = vec!["test 1".to_owned(), "test 2".to_owned(), "test 3".to_owned()]; + + assert_iter_eq!(radarr_data.sorted_quality_profile_names(), expected_quality_profile_vec); + } + #[test] fn test_radarr_data_defaults() { let radarr_data = RadarrData::default(); diff --git a/src/models/servarr_data/sonarr/sonarr_data_tests.rs b/src/models/servarr_data/sonarr/sonarr_data_tests.rs index 2a70d40..641a5f6 100644 --- a/src/models/servarr_data/sonarr/sonarr_data_tests.rs +++ b/src/models/servarr_data/sonarr/sonarr_data_tests.rs @@ -1,9 +1,10 @@ #[cfg(test)] mod tests { mod sonarr_data_tests { + use bimap::BiMap; use chrono::{DateTime, Utc}; use pretty_assertions::{assert_eq, assert_str_eq}; - + use serde_json::Number; use crate::app::sonarr::sonarr_context_clues::SERIES_HISTORY_CONTEXT_CLUES; use crate::models::sonarr_models::{Season, SonarrHistoryItem}; use crate::models::stateful_table::StatefulTable; @@ -77,6 +78,50 @@ mod tests { assert_eq!(sonarr_data.series_info_tabs.index, 0); } + #[test] + fn test_tag_ids_to_display() { + let mut tags_map = BiMap::new(); + tags_map.insert(3, "test 3".to_owned()); + tags_map.insert(2, "test 2".to_owned()); + tags_map.insert(1, "test 1".to_owned()); + let sonarr_data = SonarrData { + tags_map, + ..SonarrData::default() + }; + + assert_str_eq!(sonarr_data.tag_ids_to_display(&[Number::from(1), Number::from(2)]), "test 1, test 2"); + } + + #[test] + fn test_sorted_quality_profile_names() { + let mut quality_profile_map = BiMap::new(); + quality_profile_map.insert(3, "test 3".to_owned()); + quality_profile_map.insert(2, "test 2".to_owned()); + quality_profile_map.insert(1, "test 1".to_owned()); + let sonarr_data = SonarrData { + quality_profile_map, + ..SonarrData::default() + }; + let expected_quality_profile_vec = vec!["test 1".to_owned(), "test 2".to_owned(), "test 3".to_owned()]; + + assert_iter_eq!(sonarr_data.sorted_quality_profile_names(), expected_quality_profile_vec); + } + + #[test] + fn test_sorted_language_profile_names() { + let mut language_profiles_map = BiMap::new(); + language_profiles_map.insert(3, "test 3".to_owned()); + language_profiles_map.insert(2, "test 2".to_owned()); + language_profiles_map.insert(1, "test 1".to_owned()); + let sonarr_data = SonarrData { + language_profiles_map, + ..SonarrData::default() + }; + let expected_language_profiles_vec = vec!["test 1".to_owned(), "test 2".to_owned(), "test 3".to_owned()]; + + assert_iter_eq!(sonarr_data.sorted_language_profile_names(), expected_language_profiles_vec); + } + #[test] fn test_sonarr_data_defaults() { let sonarr_data = SonarrData::default(); diff --git a/src/network/lidarr_network/library/mod.rs b/src/network/lidarr_network/library/mod.rs index 9765cd1..a9264e0 100644 --- a/src/network/lidarr_network/library/mod.rs +++ b/src/network/lidarr_network/library/mod.rs @@ -3,7 +3,7 @@ use log::{debug, info, warn}; use serde_json::{Value, json}; use crate::models::Route; -use crate::models::lidarr_models::{Artist, DeleteArtistParams}; +use crate::models::lidarr_models::{Artist, DeleteArtistParams, EditArtistParams}; use crate::models::servarr_data::lidarr::lidarr_data::ActiveLidarrBlock; use crate::models::servarr_models::CommandBody; use crate::network::lidarr_network::LidarrEvent; @@ -168,4 +168,119 @@ impl Network<'_, '_> { .handle_request::(request_props, |_, _| ()) .await } + + pub(in crate::network::lidarr_network) async fn edit_artist( + &mut self, + mut edit_artist_params: EditArtistParams, + ) -> Result<()> { + info!("Editing Lidarr artist"); + if let Some(tag_input_str) = edit_artist_params.tag_input_string.as_ref() { + let tag_ids_vec = self.extract_and_add_lidarr_tag_ids_vec(tag_input_str).await; + edit_artist_params.tags = Some(tag_ids_vec); + } + let artist_id = edit_artist_params.artist_id; + let detail_event = LidarrEvent::GetArtistDetails(artist_id); + let event = LidarrEvent::EditArtist(EditArtistParams::default()); + info!("Fetching artist details for artist with ID: {artist_id}"); + + let request_props = self + .request_props_from( + detail_event, + RequestMethod::Get, + None::<()>, + Some(format!("/{artist_id}")), + None, + ) + .await; + + let mut response = String::new(); + + self + .handle_request::<(), Value>(request_props, |detailed_artist_body, _| { + response = detailed_artist_body.to_string() + }) + .await?; + + info!("Constructing edit artist body"); + + let mut detailed_artist_body: Value = serde_json::from_str(&response)?; + let ( + monitored, + monitor_new_items, + quality_profile_id, + metadata_profile_id, + root_folder_path, + tags, + ) = { + let monitored = edit_artist_params.monitored.unwrap_or( + detailed_artist_body["monitored"] + .as_bool() + .expect("Unable to deserialize 'monitored'"), + ); + let monitor_new_items = edit_artist_params.monitor_new_items.unwrap_or_else(|| { + serde_json::from_value(detailed_artist_body["monitorNewItems"].clone()) + .expect("Unable to deserialize 'monitorNewItems'") + }); + let quality_profile_id = edit_artist_params.quality_profile_id.unwrap_or_else(|| { + detailed_artist_body["qualityProfileId"] + .as_i64() + .expect("Unable to deserialize 'qualityProfileId'") + }); + let metadata_profile_id = edit_artist_params.metadata_profile_id.unwrap_or_else(|| { + detailed_artist_body["metadataProfileId"] + .as_i64() + .expect("Unable to deserialize 'metadataProfileId'") + }); + let root_folder_path = edit_artist_params.root_folder_path.unwrap_or_else(|| { + detailed_artist_body["path"] + .as_str() + .expect("Unable to deserialize 'path'") + .to_owned() + }); + let tags = if edit_artist_params.clear_tags { + vec![] + } else { + edit_artist_params.tags.unwrap_or( + detailed_artist_body["tags"] + .as_array() + .expect("Unable to deserialize 'tags'") + .iter() + .map(|item| item.as_i64().expect("Unable to deserialize tag ID")) + .collect(), + ) + }; + + ( + monitored, + monitor_new_items, + quality_profile_id, + metadata_profile_id, + root_folder_path, + tags, + ) + }; + + *detailed_artist_body.get_mut("monitored").unwrap() = json!(monitored); + *detailed_artist_body.get_mut("monitorNewItems").unwrap() = json!(monitor_new_items); + *detailed_artist_body.get_mut("qualityProfileId").unwrap() = json!(quality_profile_id); + *detailed_artist_body.get_mut("metadataProfileId").unwrap() = json!(metadata_profile_id); + *detailed_artist_body.get_mut("path").unwrap() = json!(root_folder_path); + *detailed_artist_body.get_mut("tags").unwrap() = json!(tags); + + debug!("Edit artist body: {detailed_artist_body:?}"); + + let request_props = self + .request_props_from( + event, + RequestMethod::Put, + Some(detailed_artist_body), + Some(format!("/{artist_id}")), + None, + ) + .await; + + self + .handle_request::(request_props, |_, _| ()) + .await + } } diff --git a/src/network/lidarr_network/lidarr_network_test_utils.rs b/src/network/lidarr_network/lidarr_network_test_utils.rs new file mode 100644 index 0000000..8677e6c --- /dev/null +++ b/src/network/lidarr_network/lidarr_network_test_utils.rs @@ -0,0 +1,132 @@ +#[cfg(test)] +#[allow(dead_code)] // TODO: maybe remove? +pub mod test_utils { + use crate::models::lidarr_models::{Artist, ArtistStatistics, ArtistStatus, DownloadRecord, DownloadStatus, DownloadsResponse, Member, MetadataProfile, NewItemMonitorType, Ratings, SystemStatus}; + use crate::models::servarr_models::{QualityProfile, RootFolder, Tag}; + use crate::models::HorizontallyScrollableText; + use bimap::BiMap; + use chrono::DateTime; + use serde_json::Number; + + pub fn member() -> Member { + Member { + name: Some("alex".to_owned()), + instrument: Some("piano".to_owned()) + } + } + + pub fn ratings() -> Ratings { + Ratings { + votes: 15, + value: 8.4 + } + } + + pub fn artist_statistics() -> ArtistStatistics { + ArtistStatistics { + album_count: 1, + track_file_count: 15, + track_count: 15, + total_track_count: 15, + size_on_disk: 12345, + percent_of_tracks: 99.9 + } + } + + pub fn artist() -> Artist { + Artist { + id: 1, + artist_name: "Alex".into(), + foreign_artist_id: "test-foreign-id".to_owned(), + status: ArtistStatus::Continuing, + overview: Some("some interesting description of the artist".to_owned()), + artist_type: Some("Person".to_owned()), + disambiguation: Some("American pianist".to_owned()), + members: Some(vec![member()]), + path: "/nfs/music/test-artist".to_owned(), + quality_profile_id: quality_profile().id, + metadata_profile_id: metadata_profile().id, + monitored: true, + monitor_new_items: NewItemMonitorType::All, + genres: vec!["soundtrack".to_owned()], + tags: vec![Number::from(tag().id)], + added: DateTime::from(DateTime::parse_from_rfc3339("2023-01-01T00:00:00Z").unwrap()), + ratings: Some(ratings()), + statistics: Some(artist_statistics()) + } + } + + pub fn quality_profile() -> QualityProfile { + QualityProfile { + id: 1, + name: "Lossless".to_owned() + } + } + + pub fn quality_profile_map() -> BiMap { + let quality_profile = quality_profile(); + BiMap::from_iter(vec![(quality_profile.id, quality_profile.name)]) + } + + pub fn metadata_profile() -> MetadataProfile { + MetadataProfile { + id: 1, + name: "Standard".to_owned() + } + } + + pub fn metadata_profile_map() -> BiMap { + let metadata_profile = metadata_profile(); + BiMap::from_iter(vec![(metadata_profile.id, metadata_profile.name)]) + } + + pub fn tag() -> Tag { + Tag { + id: 1, + label: "alex".to_owned() + } + } + + pub fn tags_map() -> BiMap { + let tag = tag(); + BiMap::from_iter(vec![(tag.id, tag.label)]) + } + + pub fn download_record() -> DownloadRecord { + DownloadRecord { + title: "Test download title".to_owned(), + status: DownloadStatus::Downloading, + id: 1, + album_id: Some(Number::from(1i64)), + artist_id: Some(Number::from(1i64)), + size: 3543348019f64, + sizeleft: 1771674009f64, + output_path: Some(HorizontallyScrollableText::from("/nfs/music/alex/album")), + indexer: "kickass torrents".to_owned(), + download_client: Some("transmission".to_owned()) + } + } + + pub fn downloads_response() -> DownloadsResponse { + DownloadsResponse { + records: vec![download_record()] + } + } + + pub fn system_status() -> SystemStatus { + SystemStatus { + version: "1.0".to_owned(), + start_time: DateTime::from(DateTime::parse_from_rfc3339("2023-01-01T00:00:00Z").unwrap()), + } + } + + pub fn root_folder() -> RootFolder { + RootFolder { + id: 1, + path: "/nfs".to_owned(), + accessible: true, + free_space: 219902325555200, + unmapped_folders: None, + } + } +} \ No newline at end of file diff --git a/src/network/lidarr_network/mod.rs b/src/network/lidarr_network/mod.rs index bdb5810..068a090 100644 --- a/src/network/lidarr_network/mod.rs +++ b/src/network/lidarr_network/mod.rs @@ -2,7 +2,9 @@ use anyhow::Result; use log::info; use super::{NetworkEvent, NetworkResource}; -use crate::models::lidarr_models::{DeleteArtistParams, LidarrSerdeable, MetadataProfile}; +use crate::models::lidarr_models::{ + DeleteArtistParams, EditArtistParams, LidarrSerdeable, MetadataProfile, +}; use crate::models::servarr_models::{QualityProfile, Tag}; use crate::network::{Network, RequestMethod}; @@ -15,9 +17,15 @@ mod system; #[path = "lidarr_network_tests.rs"] mod lidarr_network_tests; +#[cfg(test)] +#[path = "lidarr_network_test_utils.rs"] +pub mod lidarr_network_test_utils; + #[derive(Debug, Eq, PartialEq, Clone)] pub enum LidarrEvent { + AddTag(String), DeleteArtist(DeleteArtistParams), + EditArtist(EditArtistParams), GetArtistDetails(i64), GetDiskSpace, GetDownloads(u64), @@ -37,7 +45,9 @@ pub enum LidarrEvent { impl NetworkResource for LidarrEvent { fn resource(&self) -> &'static str { match &self { + LidarrEvent::AddTag(_) | LidarrEvent::GetTags => "/tag", LidarrEvent::DeleteArtist(_) + | LidarrEvent::EditArtist(_) | LidarrEvent::GetArtistDetails(_) | LidarrEvent::ListArtists | LidarrEvent::ToggleArtistMonitoring(_) => "/artist", @@ -49,7 +59,6 @@ impl NetworkResource for LidarrEvent { LidarrEvent::GetQualityProfiles => "/qualityprofile", LidarrEvent::GetRootFolders => "/rootfolder", LidarrEvent::GetStatus => "/system/status", - LidarrEvent::GetTags => "/tag", LidarrEvent::HealthCheck => "/health", } } @@ -67,6 +76,7 @@ impl Network<'_, '_> { lidarr_event: LidarrEvent, ) -> Result { match lidarr_event { + LidarrEvent::AddTag(tag) => self.add_lidarr_tag(tag).await.map(LidarrSerdeable::from), LidarrEvent::DeleteArtist(params) => { self.delete_artist(params).await.map(LidarrSerdeable::from) } @@ -111,6 +121,7 @@ impl Network<'_, '_> { .await .map(LidarrSerdeable::from), LidarrEvent::UpdateAllArtists => self.update_all_artists().await.map(LidarrSerdeable::from), + LidarrEvent::EditArtist(params) => self.edit_artist(params).await.map(LidarrSerdeable::from), } } @@ -180,4 +191,61 @@ impl Network<'_, '_> { }) .await } + + async fn add_lidarr_tag(&mut self, tag: String) -> Result { + info!("Adding a new Lidarr tag"); + let event = LidarrEvent::AddTag(String::new()); + + let request_props = self + .request_props_from( + event, + RequestMethod::Post, + Some(serde_json::json!({ "label": tag })), + None, + None, + ) + .await; + + self + .handle_request::(request_props, |tag, mut app| { + app.data.lidarr_data.tags_map.insert(tag.id, tag.label); + }) + .await + } + + pub(in crate::network::lidarr_network) async fn extract_and_add_lidarr_tag_ids_vec( + &mut self, + edit_tags: &str, + ) -> Vec { + let missing_tags_vec = { + let tags_map = &self.app.lock().await.data.lidarr_data.tags_map; + edit_tags + .split(',') + .filter(|&tag| { + !tag.is_empty() && tags_map.get_by_right(tag.to_lowercase().trim()).is_none() + }) + .collect::>() + }; + + for tag in missing_tags_vec { + self + .add_lidarr_tag(tag.trim().to_owned()) + .await + .expect("Unable to add tag"); + } + + let app = self.app.lock().await; + edit_tags + .split(',') + .filter(|tag| !tag.is_empty()) + .map(|tag| { + *app + .data + .lidarr_data + .tags_map + .get_by_right(tag.to_lowercase().trim()) + .unwrap() + }) + .collect() + } } diff --git a/src/network/sonarr_network/sonarr_network_test_utils.rs b/src/network/sonarr_network/sonarr_network_test_utils.rs index e3fc28c..f2d732e 100644 --- a/src/network/sonarr_network/sonarr_network_test_utils.rs +++ b/src/network/sonarr_network/sonarr_network_test_utils.rs @@ -284,6 +284,7 @@ pub mod test_utils { subtitles: Some("English".to_owned()), } } + pub fn quality() -> Quality { Quality { name: "Bluray-1080p".to_owned(), diff --git a/src/ui/lidarr_ui/library/edit_artist_ui.rs b/src/ui/lidarr_ui/library/edit_artist_ui.rs new file mode 100644 index 0000000..7137904 --- /dev/null +++ b/src/ui/lidarr_ui/library/edit_artist_ui.rs @@ -0,0 +1,222 @@ +use std::sync::atomic::Ordering; + +use ratatui::Frame; +use ratatui::layout::{Constraint, Rect}; +use ratatui::prelude::Layout; +use ratatui::widgets::ListItem; + +use crate::app::App; +use crate::models::Route; +use crate::models::servarr_data::lidarr::lidarr_data::{ActiveLidarrBlock, EDIT_ARTIST_BLOCKS}; +use crate::models::servarr_data::lidarr::modals::EditArtistModal; +use crate::render_selectable_input_box; + +use crate::ui::styles::ManagarrStyle; +use crate::ui::utils::{title_block_centered}; +use crate::ui::widgets::button::Button; +use crate::ui::widgets::checkbox::Checkbox; +use crate::ui::widgets::input_box::InputBox; +use crate::ui::widgets::popup::{Popup, Size}; +use crate::ui::widgets::selectable_list::SelectableList; +use crate::ui::{DrawUi, draw_popup}; + +#[cfg(test)] +#[path = "edit_artist_ui_tests.rs"] +mod edit_artist_ui_tests; + +pub(super) struct EditArtistUi; + +impl DrawUi for EditArtistUi { + fn accepts(route: Route) -> bool { + let Route::Lidarr(active_lidarr_block, _) = route else { + return false; + }; + EDIT_ARTIST_BLOCKS.contains(&active_lidarr_block) + } + + fn draw(f: &mut Frame<'_>, app: &mut App<'_>, _area: Rect) { + if let Route::Lidarr(active_lidarr_block, _context_option) = app.get_current_route() { + let draw_edit_artist_prompt = |f: &mut Frame<'_>, app: &mut App<'_>, prompt_area: Rect| { + draw_edit_artist_confirmation_prompt(f, app, prompt_area); + + match active_lidarr_block { + ActiveLidarrBlock::EditArtistSelectMonitorNewItems => { + draw_edit_artist_select_monitor_new_items_popup(f, app); + } + ActiveLidarrBlock::EditArtistSelectQualityProfile => { + draw_edit_artist_select_quality_profile_popup(f, app); + } + ActiveLidarrBlock::EditArtistSelectMetadataProfile => { + draw_edit_artist_select_metadata_profile_popup(f, app); + } + _ => (), + } + }; + + draw_popup(f, app, draw_edit_artist_prompt, Size::Long); + } + } +} + +fn draw_edit_artist_confirmation_prompt(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { + let artist_name = app + .data + .lidarr_data + .artists + .current_selection() + .artist_name + .text + .clone(); + let title = format!("Edit - {artist_name}"); + f.render_widget(title_block_centered(&title), area); + + let yes_no_value = app.data.lidarr_data.prompt_confirm; + let selected_block = app.data.lidarr_data.selected_block.get_active_block(); + let highlight_yes_no = selected_block == ActiveLidarrBlock::EditArtistConfirmPrompt; + let EditArtistModal { + monitor_list, + quality_profile_list, + metadata_profile_list, + monitored, + path, + tags, + } = app + .data + .lidarr_data + .edit_artist_modal + .as_ref() + .expect("edit_artist_modal must exist in this context"); + let selected_monitor_new_items = monitor_list.current_selection(); + let selected_quality_profile = quality_profile_list.current_selection(); + let selected_metadata_profile = metadata_profile_list.current_selection(); + + let [ + _, + monitored_area, + monitor_new_items_area, + quality_profile_area, + metadata_profile_area, + path_area, + tags_area, + _, + buttons_area, + ] = Layout::vertical([ + Constraint::Fill(1), + Constraint::Length(3), + Constraint::Length(3), + Constraint::Length(3), + Constraint::Length(3), + Constraint::Length(3), + Constraint::Length(3), + Constraint::Fill(1), + Constraint::Length(3), + ]) + .margin(1) + .areas(area); + let [save_area, cancel_area] = + Layout::horizontal([Constraint::Percentage(50), Constraint::Percentage(50)]) + .areas(buttons_area); + + let monitored_checkbox = Checkbox::new("Monitored") + .checked(monitored.unwrap_or_default()) + .highlighted(selected_block == ActiveLidarrBlock::EditArtistToggleMonitored); + let monitor_new_items_drop_down_button = Button::new() + .title(selected_monitor_new_items.to_display_str()) + .label("Monitor New Albums") + .icon("▼") + .selected(selected_block == ActiveLidarrBlock::EditArtistSelectMonitorNewItems); + let quality_profile_drop_down_button = Button::new() + .title(selected_quality_profile) + .label("Quality Profile") + .icon("▼") + .selected(selected_block == ActiveLidarrBlock::EditArtistSelectQualityProfile); + let metadata_profile_drop_down_button = Button::new() + .title(selected_metadata_profile) + .label("Metadata Profile") + .icon("▼") + .selected(selected_block == ActiveLidarrBlock::EditArtistSelectMetadataProfile); + + if let Route::Lidarr(active_lidarr_block, _) = app.get_current_route() { + let path_input_box = InputBox::new(&path.text) + .offset(path.offset.load(Ordering::SeqCst)) + .label("Path") + .highlighted(selected_block == ActiveLidarrBlock::EditArtistPathInput) + .selected(active_lidarr_block == ActiveLidarrBlock::EditArtistPathInput); + let tags_input_box = InputBox::new(&tags.text) + .offset(tags.offset.load(Ordering::SeqCst)) + .label("Tags") + .highlighted(selected_block == ActiveLidarrBlock::EditArtistTagsInput) + .selected(active_lidarr_block == ActiveLidarrBlock::EditArtistTagsInput); + + match active_lidarr_block { + ActiveLidarrBlock::EditArtistPathInput => path_input_box.show_cursor(f, path_area), + ActiveLidarrBlock::EditArtistTagsInput => tags_input_box.show_cursor(f, tags_area), + _ => (), + } + + render_selectable_input_box!(path_input_box, f, path_area); + render_selectable_input_box!(tags_input_box, f, tags_area); + } + + let save_button = Button::new() + .title("Save") + .selected(yes_no_value && highlight_yes_no); + let cancel_button = Button::new() + .title("Cancel") + .selected(!yes_no_value && highlight_yes_no); + + f.render_widget(monitored_checkbox, monitored_area); + f.render_widget(monitor_new_items_drop_down_button, monitor_new_items_area); + f.render_widget(quality_profile_drop_down_button, quality_profile_area); + f.render_widget(metadata_profile_drop_down_button, metadata_profile_area); + f.render_widget(save_button, save_area); + f.render_widget(cancel_button, cancel_area); +} + +fn draw_edit_artist_select_monitor_new_items_popup(f: &mut Frame<'_>, app: &mut App<'_>) { + let monitor_list = SelectableList::new( + &mut app + .data + .lidarr_data + .edit_artist_modal + .as_mut() + .expect("edit_artist_modal must exist in this context") + .monitor_list, + |monitor_type| ListItem::new(monitor_type.to_display_str().to_owned()), + ); + let popup = Popup::new(monitor_list).size(Size::Dropdown); + + f.render_widget(popup, f.area()); +} + +fn draw_edit_artist_select_quality_profile_popup(f: &mut Frame<'_>, app: &mut App<'_>) { + let quality_profile_list = SelectableList::new( + &mut app + .data + .lidarr_data + .edit_artist_modal + .as_mut() + .expect("edit_artist_modal must exist in this context") + .quality_profile_list, + |quality_profile| ListItem::new(quality_profile.clone()), + ); + let popup = Popup::new(quality_profile_list).size(Size::Dropdown); + + f.render_widget(popup, f.area()); +} + +fn draw_edit_artist_select_metadata_profile_popup(f: &mut Frame<'_>, app: &mut App<'_>) { + let metadata_profile_list = SelectableList::new( + &mut app + .data + .lidarr_data + .edit_artist_modal + .as_mut() + .expect("edit_artist_modal must exist in this context") + .metadata_profile_list, + |metadata_profile| ListItem::new(metadata_profile.clone()), + ); + let popup = Popup::new(metadata_profile_list).size(Size::Dropdown); + + f.render_widget(popup, f.area()); +} diff --git a/src/ui/lidarr_ui/library/edit_artist_ui_tests.rs b/src/ui/lidarr_ui/library/edit_artist_ui_tests.rs new file mode 100644 index 0000000..2980d1d --- /dev/null +++ b/src/ui/lidarr_ui/library/edit_artist_ui_tests.rs @@ -0,0 +1,22 @@ +#[cfg(test)] +mod tests { + use pretty_assertions::assert_eq; + use strum::IntoEnumIterator; + + use crate::models::Route; + use crate::models::servarr_data::lidarr::lidarr_data::{ActiveLidarrBlock, EDIT_ARTIST_BLOCKS}; + use crate::ui::DrawUi; + use crate::ui::lidarr_ui::library::edit_artist_ui::EditArtistUi; + + #[test] + fn test_edit_artist_ui_accepts() { + let mut edit_artist_ui_blocks = Vec::new(); + for block in ActiveLidarrBlock::iter() { + if EditArtistUi::accepts(Route::Lidarr(block, None)) { + edit_artist_ui_blocks.push(block); + } + } + + assert_eq!(edit_artist_ui_blocks, EDIT_ARTIST_BLOCKS.to_vec()); + } +} diff --git a/src/ui/lidarr_ui/library/library_ui_tests.rs b/src/ui/lidarr_ui/library/library_ui_tests.rs index 1be94f4..645be1c 100644 --- a/src/ui/lidarr_ui/library/library_ui_tests.rs +++ b/src/ui/lidarr_ui/library/library_ui_tests.rs @@ -4,7 +4,7 @@ mod tests { use crate::models::lidarr_models::{Artist, ArtistStatistics, ArtistStatus}; use crate::models::servarr_data::lidarr::lidarr_data::{ - ActiveLidarrBlock, DELETE_ARTIST_BLOCKS, LIBRARY_BLOCKS, + ActiveLidarrBlock, DELETE_ARTIST_BLOCKS, EDIT_ARTIST_BLOCKS, LIBRARY_BLOCKS, }; use crate::ui::DrawUi; use crate::ui::lidarr_ui::library::{LibraryUi, decorate_artist_row_with_style}; @@ -17,6 +17,8 @@ mod tests { let mut library_ui_blocks = Vec::new(); library_ui_blocks.extend(LIBRARY_BLOCKS); library_ui_blocks.extend(DELETE_ARTIST_BLOCKS); + library_ui_blocks.extend(EDIT_ARTIST_BLOCKS); + for active_lidarr_block in ActiveLidarrBlock::iter() { if library_ui_blocks.contains(&active_lidarr_block) { assert!(LibraryUi::accepts(active_lidarr_block.into())); diff --git a/src/ui/lidarr_ui/library/mod.rs b/src/ui/lidarr_ui/library/mod.rs index 064181b..57c2c6c 100644 --- a/src/ui/lidarr_ui/library/mod.rs +++ b/src/ui/lidarr_ui/library/mod.rs @@ -1,4 +1,5 @@ use delete_artist_ui::DeleteArtistUi; +use edit_artist_ui::EditArtistUi; use ratatui::{ Frame, layout::{Constraint, Rect}, @@ -26,6 +27,7 @@ use crate::{ }; mod delete_artist_ui; +mod edit_artist_ui; #[cfg(test)] #[path = "library_ui_tests.rs"] @@ -36,7 +38,9 @@ pub(super) struct LibraryUi; impl DrawUi for LibraryUi { fn accepts(route: Route) -> bool { if let Route::Lidarr(active_lidarr_block, _) = route { - return DeleteArtistUi::accepts(route) || LIBRARY_BLOCKS.contains(&active_lidarr_block); + return DeleteArtistUi::accepts(route) + || EditArtistUi::accepts(route) + || LIBRARY_BLOCKS.contains(&active_lidarr_block); } false @@ -48,6 +52,7 @@ impl DrawUi for LibraryUi { match route { _ if DeleteArtistUi::accepts(route) => DeleteArtistUi::draw(f, app, area), + _ if EditArtistUi::accepts(route) => EditArtistUi::draw(f, app, area), Route::Lidarr(ActiveLidarrBlock::UpdateAllArtistsPrompt, _) => { let confirmation_prompt = ConfirmationPrompt::new() .title("Update All Artists") -- 2.52.0 From b1afdaf5417fb74edf0b697c8dedd64aaa7611fc Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Wed, 7 Jan 2026 12:01:03 -0700 Subject: [PATCH 13/61] feat: Added CLI and TUI support for editing Lidarr artists --- .../library/delete_artist_handler.rs | 2 +- .../library/edit_artist_handler.rs | 2 +- .../library/edit_artist_handler_tests.rs | 1304 ++++++++++++++++- .../library/library_handler_tests.rs | 143 +- src/handlers/lidarr_handlers/library/mod.rs | 9 +- .../lidarr_handlers/lidarr_handler_tests.rs | 48 +- src/handlers/lidarr_handlers/mod.rs | 4 +- src/handlers/radarr_handlers/blocklist/mod.rs | 2 +- .../collections/collection_details_handler.rs | 2 +- .../collections/edit_collection_handler.rs | 2 +- .../radarr_handlers/collections/mod.rs | 2 +- .../indexers/edit_indexer_handler.rs | 2 +- .../indexers/edit_indexer_settings_handler.rs | 2 +- src/handlers/radarr_handlers/indexers/mod.rs | 2 +- .../library/delete_movie_handler.rs | 2 +- .../library/edit_movie_handler.rs | 2 +- src/handlers/radarr_handlers/mod.rs | 2 +- .../radarr_handlers/root_folders/mod.rs | 2 +- src/handlers/radarr_handlers/system/mod.rs | 2 +- .../system/system_details_handler.rs | 2 +- .../indexers/edit_indexer_handler.rs | 2 +- .../indexers/edit_indexer_settings_handler.rs | 2 +- src/handlers/sonarr_handlers/indexers/mod.rs | 2 +- .../library/delete_series_handler.rs | 2 +- .../library/edit_series_handler.rs | 2 +- .../library/season_details_handler.rs | 2 +- .../library/series_details_handler.rs | 2 +- src/handlers/sonarr_handlers/mod.rs | 4 +- .../sonarr_handlers/root_folders/mod.rs | 2 +- src/handlers/sonarr_handlers/system/mod.rs | 2 +- .../system/system_details_handler.rs | 2 +- src/handlers/table_handler_tests.rs | 2 +- src/models/servarr_data/lidarr/lidarr_data.rs | 30 +- .../servarr_data/lidarr/lidarr_data_tests.rs | 69 +- .../servarr_data/lidarr/modals_tests.rs | 12 +- .../servarr_data/radarr/radarr_data_tests.rs | 24 +- .../servarr_data/sonarr/sonarr_data_tests.rs | 35 +- .../library/lidarr_library_network_tests.rs | 236 ++- .../lidarr_network_test_utils.rs | 279 ++-- .../lidarr_network/lidarr_network_tests.rs | 92 ++ src/ui/lidarr_ui/library/edit_artist_ui.rs | 4 +- .../lidarr_ui/library/edit_artist_ui_tests.rs | 45 +- ...elete_artist_ui_renders_delete_artist.snap | 2 +- ...__edit_artist_EditArtistConfirmPrompt.snap | 48 + ...t_tests__edit_artist_EditArtistPrompt.snap | 48 + ...rtist_EditArtistSelectMetadataProfile.snap | 48 + ...rtist_EditArtistSelectMonitorNewItems.snap | 48 + ...artist_EditArtistSelectQualityProfile.snap | 48 + ...ui_renders_delete_artist_over_library.snap | 2 +- 49 files changed, 2338 insertions(+), 296 deletions(-) create mode 100644 src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__edit_artist_ui__edit_artist_ui_tests__tests__snapshot_tests__edit_artist_EditArtistConfirmPrompt.snap create mode 100644 src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__edit_artist_ui__edit_artist_ui_tests__tests__snapshot_tests__edit_artist_EditArtistPrompt.snap create mode 100644 src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__edit_artist_ui__edit_artist_ui_tests__tests__snapshot_tests__edit_artist_EditArtistSelectMetadataProfile.snap create mode 100644 src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__edit_artist_ui__edit_artist_ui_tests__tests__snapshot_tests__edit_artist_EditArtistSelectMonitorNewItems.snap create mode 100644 src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__edit_artist_ui__edit_artist_ui_tests__tests__snapshot_tests__edit_artist_EditArtistSelectQualityProfile.snap diff --git a/src/handlers/lidarr_handlers/library/delete_artist_handler.rs b/src/handlers/lidarr_handlers/library/delete_artist_handler.rs index 31affd7..89fc39d 100644 --- a/src/handlers/lidarr_handlers/library/delete_artist_handler.rs +++ b/src/handlers/lidarr_handlers/library/delete_artist_handler.rs @@ -1,3 +1,4 @@ +use crate::models::Route; use crate::models::lidarr_models::DeleteArtistParams; use crate::network::lidarr_network::LidarrEvent; use crate::{ @@ -7,7 +8,6 @@ use crate::{ matches_key, models::servarr_data::lidarr::lidarr_data::{ActiveLidarrBlock, DELETE_ARTIST_BLOCKS}, }; -use crate::models::Route; #[cfg(test)] #[path = "delete_artist_handler_tests.rs"] diff --git a/src/handlers/lidarr_handlers/library/edit_artist_handler.rs b/src/handlers/lidarr_handlers/library/edit_artist_handler.rs index 3b62fae..194a5e0 100644 --- a/src/handlers/lidarr_handlers/library/edit_artist_handler.rs +++ b/src/handlers/lidarr_handlers/library/edit_artist_handler.rs @@ -1,10 +1,10 @@ use crate::app::App; use crate::event::Key; use crate::handlers::{KeyEventHandler, handle_prompt_toggle}; -use crate::models::{Route, Scrollable}; use crate::models::lidarr_models::EditArtistParams; use crate::models::servarr_data::lidarr::lidarr_data::{ActiveLidarrBlock, EDIT_ARTIST_BLOCKS}; use crate::models::servarr_data::lidarr::modals::EditArtistModal; +use crate::models::{Route, Scrollable}; use crate::network::lidarr_network::LidarrEvent; use crate::{handle_text_box_keys, handle_text_box_left_right_keys, matches_key}; diff --git a/src/handlers/lidarr_handlers/library/edit_artist_handler_tests.rs b/src/handlers/lidarr_handlers/library/edit_artist_handler_tests.rs index 1f1ed9b..fee625c 100644 --- a/src/handlers/lidarr_handlers/library/edit_artist_handler_tests.rs +++ b/src/handlers/lidarr_handlers/library/edit_artist_handler_tests.rs @@ -1,28 +1,214 @@ #[cfg(test)] mod tests { - use pretty_assertions::assert_eq; + use bimap::BiMap; + use pretty_assertions::{assert_eq, assert_str_eq}; + use rstest::rstest; use strum::IntoEnumIterator; use crate::app::App; use crate::app::key_binding::DEFAULT_KEYBINDINGS; + use crate::assert_modal_absent; + use crate::assert_navigation_pushed; use crate::event::Key; use crate::handlers::KeyEventHandler; use crate::handlers::lidarr_handlers::library::edit_artist_handler::EditArtistHandler; + use crate::models::lidarr_models::{Artist, EditArtistParams, NewItemMonitorType}; use crate::models::servarr_data::lidarr::lidarr_data::{ActiveLidarrBlock, EDIT_ARTIST_BLOCKS}; use crate::models::servarr_data::lidarr::modals::EditArtistModal; + use crate::network::lidarr_network::LidarrEvent; mod test_handle_scroll_up_and_down { use pretty_assertions::assert_eq; use rstest::rstest; + use strum::IntoEnumIterator; use crate::models::BlockSelectionState; use crate::models::servarr_data::lidarr::lidarr_data::EDIT_ARTIST_SELECTION_BLOCKS; use super::*; + #[rstest] + fn test_edit_artist_select_monitor_new_items_scroll( + #[values(DEFAULT_KEYBINDINGS.up.key, DEFAULT_KEYBINDINGS.down.key)] key: Key, + ) { + let monitor_type_vec = Vec::from_iter(NewItemMonitorType::iter()); + let mut app = App::test_default(); + app.push_navigation_stack(ActiveLidarrBlock::Artists.into()); + app.data.lidarr_data.edit_artist_modal = Some(EditArtistModal::default()); + app + .data + .lidarr_data + .edit_artist_modal + .as_mut() + .unwrap() + .monitor_list + .set_items(monitor_type_vec.clone()); + + if key == Key::Up { + for i in (0..monitor_type_vec.len()).rev() { + EditArtistHandler::new( + key, + &mut app, + ActiveLidarrBlock::EditArtistSelectMonitorNewItems, + None, + ) + .handle(); + + assert_eq!( + app + .data + .lidarr_data + .edit_artist_modal + .as_ref() + .unwrap() + .monitor_list + .current_selection(), + &monitor_type_vec[i] + ); + } + } else { + for i in 0..monitor_type_vec.len() { + EditArtistHandler::new( + key, + &mut app, + ActiveLidarrBlock::EditArtistSelectMonitorNewItems, + None, + ) + .handle(); + + assert_eq!( + app + .data + .lidarr_data + .edit_artist_modal + .as_ref() + .unwrap() + .monitor_list + .current_selection(), + &monitor_type_vec[(i + 1) % monitor_type_vec.len()] + ); + } + } + } + + #[rstest] + fn test_edit_artist_select_quality_profile_scroll( + #[values(DEFAULT_KEYBINDINGS.up.key, DEFAULT_KEYBINDINGS.down.key)] key: Key, + ) { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveLidarrBlock::Artists.into()); + app.data.lidarr_data.edit_artist_modal = Some(EditArtistModal::default()); + app + .data + .lidarr_data + .edit_artist_modal + .as_mut() + .unwrap() + .quality_profile_list + .set_items(vec!["Test 1".to_owned(), "Test 2".to_owned()]); + + EditArtistHandler::new( + key, + &mut app, + ActiveLidarrBlock::EditArtistSelectQualityProfile, + None, + ) + .handle(); + + assert_str_eq!( + app + .data + .lidarr_data + .edit_artist_modal + .as_ref() + .unwrap() + .quality_profile_list + .current_selection(), + "Test 2" + ); + + EditArtistHandler::new( + key, + &mut app, + ActiveLidarrBlock::EditArtistSelectQualityProfile, + None, + ) + .handle(); + + assert_str_eq!( + app + .data + .lidarr_data + .edit_artist_modal + .as_ref() + .unwrap() + .quality_profile_list + .current_selection(), + "Test 1" + ); + } + + #[rstest] + fn test_edit_artist_select_metadata_profile_scroll( + #[values(DEFAULT_KEYBINDINGS.up.key, DEFAULT_KEYBINDINGS.down.key)] key: Key, + ) { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveLidarrBlock::Artists.into()); + app.data.lidarr_data.edit_artist_modal = Some(EditArtistModal::default()); + app + .data + .lidarr_data + .edit_artist_modal + .as_mut() + .unwrap() + .metadata_profile_list + .set_items(vec!["Test 1".to_owned(), "Test 2".to_owned()]); + + EditArtistHandler::new( + key, + &mut app, + ActiveLidarrBlock::EditArtistSelectMetadataProfile, + None, + ) + .handle(); + + assert_str_eq!( + app + .data + .lidarr_data + .edit_artist_modal + .as_ref() + .unwrap() + .metadata_profile_list + .current_selection(), + "Test 2" + ); + + EditArtistHandler::new( + key, + &mut app, + ActiveLidarrBlock::EditArtistSelectMetadataProfile, + None, + ) + .handle(); + + assert_str_eq!( + app + .data + .lidarr_data + .edit_artist_modal + .as_ref() + .unwrap() + .metadata_profile_list + .current_selection(), + "Test 1" + ); + } + #[rstest] fn test_edit_artist_prompt_scroll(#[values(Key::Up, Key::Down)] key: Key) { let mut app = App::test_default(); + app.push_navigation_stack(ActiveLidarrBlock::Artists.into()); app.data.lidarr_data.edit_artist_modal = Some(EditArtistModal::default()); app.data.lidarr_data.selected_block = BlockSelectionState::new(EDIT_ARTIST_SELECTION_BLOCKS); app.data.lidarr_data.selected_block.down(); @@ -45,6 +231,7 @@ mod tests { #[rstest] fn test_edit_artist_prompt_scroll_no_op_when_not_ready(#[values(Key::Up, Key::Down)] key: Key) { let mut app = App::test_default(); + app.push_navigation_stack(ActiveLidarrBlock::Artists.into()); app.is_loading = true; app.data.lidarr_data.edit_artist_modal = Some(EditArtistModal::default()); app.data.lidarr_data.selected_block = BlockSelectionState::new(EDIT_ARTIST_SELECTION_BLOCKS); @@ -59,7 +246,300 @@ mod tests { } } + mod test_handle_home_end { + use pretty_assertions::assert_eq; + use std::sync::atomic::Ordering; + + use strum::IntoEnumIterator; + + use crate::models::servarr_data::lidarr::modals::EditArtistModal; + + use super::*; + + #[test] + fn test_edit_artist_select_monitor_new_items_home_end() { + let monitor_type_vec = Vec::from_iter(NewItemMonitorType::iter()); + let mut app = App::test_default(); + app.push_navigation_stack(ActiveLidarrBlock::Artists.into()); + app.data.lidarr_data.edit_artist_modal = Some(EditArtistModal::default()); + app + .data + .lidarr_data + .edit_artist_modal + .as_mut() + .unwrap() + .monitor_list + .set_items(monitor_type_vec.clone()); + + EditArtistHandler::new( + DEFAULT_KEYBINDINGS.end.key, + &mut app, + ActiveLidarrBlock::EditArtistSelectMonitorNewItems, + None, + ) + .handle(); + + assert_eq!( + app + .data + .lidarr_data + .edit_artist_modal + .as_ref() + .unwrap() + .monitor_list + .current_selection(), + &monitor_type_vec[monitor_type_vec.len() - 1] + ); + + EditArtistHandler::new( + DEFAULT_KEYBINDINGS.home.key, + &mut app, + ActiveLidarrBlock::EditArtistSelectMonitorNewItems, + None, + ) + .handle(); + + assert_eq!( + app + .data + .lidarr_data + .edit_artist_modal + .as_ref() + .unwrap() + .monitor_list + .current_selection(), + &monitor_type_vec[0] + ); + } + + #[test] + fn test_edit_artist_select_quality_profile_home_end() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveLidarrBlock::Artists.into()); + app.data.lidarr_data.edit_artist_modal = Some(EditArtistModal::default()); + app + .data + .lidarr_data + .edit_artist_modal + .as_mut() + .unwrap() + .quality_profile_list + .set_items(vec![ + "Test 1".to_owned(), + "Test 2".to_owned(), + "Test 3".to_owned(), + ]); + + EditArtistHandler::new( + DEFAULT_KEYBINDINGS.end.key, + &mut app, + ActiveLidarrBlock::EditArtistSelectQualityProfile, + None, + ) + .handle(); + + assert_str_eq!( + app + .data + .lidarr_data + .edit_artist_modal + .as_ref() + .unwrap() + .quality_profile_list + .current_selection(), + "Test 3" + ); + + EditArtistHandler::new( + DEFAULT_KEYBINDINGS.home.key, + &mut app, + ActiveLidarrBlock::EditArtistSelectQualityProfile, + None, + ) + .handle(); + + assert_str_eq!( + app + .data + .lidarr_data + .edit_artist_modal + .as_ref() + .unwrap() + .quality_profile_list + .current_selection(), + "Test 1" + ); + } + + #[test] + fn test_edit_artist_select_metadata_profile_home_end() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveLidarrBlock::Artists.into()); + app.data.lidarr_data.edit_artist_modal = Some(EditArtistModal::default()); + app + .data + .lidarr_data + .edit_artist_modal + .as_mut() + .unwrap() + .metadata_profile_list + .set_items(vec![ + "Test 1".to_owned(), + "Test 2".to_owned(), + "Test 3".to_owned(), + ]); + + EditArtistHandler::new( + DEFAULT_KEYBINDINGS.end.key, + &mut app, + ActiveLidarrBlock::EditArtistSelectMetadataProfile, + None, + ) + .handle(); + + assert_str_eq!( + app + .data + .lidarr_data + .edit_artist_modal + .as_ref() + .unwrap() + .metadata_profile_list + .current_selection(), + "Test 3" + ); + + EditArtistHandler::new( + DEFAULT_KEYBINDINGS.home.key, + &mut app, + ActiveLidarrBlock::EditArtistSelectMetadataProfile, + None, + ) + .handle(); + + assert_str_eq!( + app + .data + .lidarr_data + .edit_artist_modal + .as_ref() + .unwrap() + .metadata_profile_list + .current_selection(), + "Test 1" + ); + } + + #[test] + fn test_edit_artist_path_input_home_end_keys() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveLidarrBlock::Artists.into()); + app.data.lidarr_data.edit_artist_modal = Some(EditArtistModal { + path: "Test".into(), + ..EditArtistModal::default() + }); + + EditArtistHandler::new( + DEFAULT_KEYBINDINGS.home.key, + &mut app, + ActiveLidarrBlock::EditArtistPathInput, + None, + ) + .handle(); + + assert_eq!( + app + .data + .lidarr_data + .edit_artist_modal + .as_ref() + .unwrap() + .path + .offset + .load(Ordering::SeqCst), + 4 + ); + + EditArtistHandler::new( + DEFAULT_KEYBINDINGS.end.key, + &mut app, + ActiveLidarrBlock::EditArtistPathInput, + None, + ) + .handle(); + + assert_eq!( + app + .data + .lidarr_data + .edit_artist_modal + .as_ref() + .unwrap() + .path + .offset + .load(Ordering::SeqCst), + 0 + ); + } + + #[test] + fn test_edit_artist_tags_input_home_end_keys() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveLidarrBlock::Artists.into()); + app.data.lidarr_data.edit_artist_modal = Some(EditArtistModal { + tags: "Test".into(), + ..EditArtistModal::default() + }); + + EditArtistHandler::new( + DEFAULT_KEYBINDINGS.home.key, + &mut app, + ActiveLidarrBlock::EditArtistTagsInput, + None, + ) + .handle(); + + assert_eq!( + app + .data + .lidarr_data + .edit_artist_modal + .as_ref() + .unwrap() + .tags + .offset + .load(Ordering::SeqCst), + 4 + ); + + EditArtistHandler::new( + DEFAULT_KEYBINDINGS.end.key, + &mut app, + ActiveLidarrBlock::EditArtistTagsInput, + None, + ) + .handle(); + + assert_eq!( + app + .data + .lidarr_data + .edit_artist_modal + .as_ref() + .unwrap() + .tags + .offset + .load(Ordering::SeqCst), + 0 + ); + } + } + mod test_handle_left_right_action { + use pretty_assertions::assert_eq; + use std::sync::atomic::Ordering; + + use crate::models::servarr_data::lidarr::modals::EditArtistModal; use rstest::rstest; use super::*; @@ -67,8 +547,8 @@ mod tests { #[rstest] fn test_left_right_prompt_toggle(#[values(Key::Left, Key::Right)] key: Key) { let mut app = App::test_default(); + app.push_navigation_stack(ActiveLidarrBlock::Artists.into()); app.data.lidarr_data.edit_artist_modal = Some(EditArtistModal::default()); - app.push_navigation_stack(ActiveLidarrBlock::EditArtistPrompt.into()); EditArtistHandler::new(key, &mut app, ActiveLidarrBlock::EditArtistPrompt, None).handle(); @@ -78,17 +558,193 @@ mod tests { assert!(!app.data.lidarr_data.prompt_confirm); } + + #[test] + fn test_edit_artist_path_input_left_right_keys() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveLidarrBlock::Artists.into()); + app.data.lidarr_data.edit_artist_modal = Some(EditArtistModal { + path: "Test".into(), + ..EditArtistModal::default() + }); + + EditArtistHandler::new( + DEFAULT_KEYBINDINGS.left.key, + &mut app, + ActiveLidarrBlock::EditArtistPathInput, + None, + ) + .handle(); + + assert_eq!( + app + .data + .lidarr_data + .edit_artist_modal + .as_ref() + .unwrap() + .path + .offset + .load(Ordering::SeqCst), + 1 + ); + + EditArtistHandler::new( + DEFAULT_KEYBINDINGS.right.key, + &mut app, + ActiveLidarrBlock::EditArtistPathInput, + None, + ) + .handle(); + + assert_eq!( + app + .data + .lidarr_data + .edit_artist_modal + .as_ref() + .unwrap() + .path + .offset + .load(Ordering::SeqCst), + 0 + ); + } + + #[test] + fn test_edit_artist_tags_input_left_right_keys() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveLidarrBlock::Artists.into()); + app.data.lidarr_data.edit_artist_modal = Some(EditArtistModal { + tags: "Test".into(), + ..EditArtistModal::default() + }); + + EditArtistHandler::new( + DEFAULT_KEYBINDINGS.left.key, + &mut app, + ActiveLidarrBlock::EditArtistTagsInput, + None, + ) + .handle(); + + assert_eq!( + app + .data + .lidarr_data + .edit_artist_modal + .as_ref() + .unwrap() + .tags + .offset + .load(Ordering::SeqCst), + 1 + ); + + EditArtistHandler::new( + DEFAULT_KEYBINDINGS.right.key, + &mut app, + ActiveLidarrBlock::EditArtistTagsInput, + None, + ) + .handle(); + + assert_eq!( + app + .data + .lidarr_data + .edit_artist_modal + .as_ref() + .unwrap() + .tags + .offset + .load(Ordering::SeqCst), + 0 + ); + } } mod test_handle_submit { - use crate::models::BlockSelectionState; + use crate::assert_navigation_popped; use crate::models::servarr_data::lidarr::lidarr_data::EDIT_ARTIST_SELECTION_BLOCKS; + use crate::models::{BlockSelectionState, Route}; + use pretty_assertions::assert_eq; + use rstest::rstest; use super::*; - use crate::assert_navigation_popped; const SUBMIT_KEY: Key = DEFAULT_KEYBINDINGS.submit.key; + #[test] + fn test_edit_artist_path_input_submit() { + let mut app = App::test_default(); + app.ignore_special_keys_for_textbox_input = true; + app.data.lidarr_data.edit_artist_modal = Some(EditArtistModal { + path: "Test Path".into(), + ..EditArtistModal::default() + }); + app.push_navigation_stack(ActiveLidarrBlock::Artists.into()); + app.push_navigation_stack(ActiveLidarrBlock::EditArtistPrompt.into()); + app.push_navigation_stack(ActiveLidarrBlock::EditArtistPathInput.into()); + + EditArtistHandler::new( + SUBMIT_KEY, + &mut app, + ActiveLidarrBlock::EditArtistPathInput, + None, + ) + .handle(); + + assert!(!app.ignore_special_keys_for_textbox_input); + assert!( + !app + .data + .lidarr_data + .edit_artist_modal + .as_ref() + .unwrap() + .path + .text + .is_empty() + ); + assert_navigation_popped!(app, ActiveLidarrBlock::EditArtistPrompt.into()); + } + + #[test] + fn test_edit_artist_tags_input_submit() { + let mut app = App::test_default(); + app.ignore_special_keys_for_textbox_input = true; + app.data.lidarr_data.edit_artist_modal = Some(EditArtistModal { + tags: "Test Tags".into(), + ..EditArtistModal::default() + }); + app.push_navigation_stack(ActiveLidarrBlock::Artists.into()); + app.push_navigation_stack(ActiveLidarrBlock::EditArtistPrompt.into()); + app.push_navigation_stack(ActiveLidarrBlock::EditArtistTagsInput.into()); + + EditArtistHandler::new( + SUBMIT_KEY, + &mut app, + ActiveLidarrBlock::EditArtistTagsInput, + None, + ) + .handle(); + + assert!(!app.ignore_special_keys_for_textbox_input); + assert!( + !app + .data + .lidarr_data + .edit_artist_modal + .as_mut() + .unwrap() + .tags + .text + .is_empty() + ); + assert_navigation_popped!(app, ActiveLidarrBlock::EditArtistPrompt.into()); + } + #[test] fn test_edit_artist_prompt_prompt_decline_submit() { let mut app = App::test_default(); @@ -96,10 +752,11 @@ mod tests { app.push_navigation_stack(ActiveLidarrBlock::Artists.into()); app.push_navigation_stack(ActiveLidarrBlock::EditArtistPrompt.into()); app.data.lidarr_data.selected_block = BlockSelectionState::new(EDIT_ARTIST_SELECTION_BLOCKS); - // Navigate to the confirm prompt (last selection block) - for _ in 0..EDIT_ARTIST_SELECTION_BLOCKS.len() - 1 { - app.data.lidarr_data.selected_block.down(); - } + app + .data + .lidarr_data + .selected_block + .set_index(0, EDIT_ARTIST_SELECTION_BLOCKS.len() - 1); EditArtistHandler::new( SUBMIT_KEY, @@ -109,72 +766,620 @@ mod tests { ) .handle(); - assert!(app.data.lidarr_data.prompt_confirm_action.is_none()); - assert_navigation_popped!(&app, ActiveLidarrBlock::Artists.into()); + assert_navigation_popped!(app, ActiveLidarrBlock::Artists.into()); + assert_none!(app.data.lidarr_data.prompt_confirm_action); } - } - - mod test_handle_esc { - use super::*; - use crate::assert_navigation_popped; - - const ESC_KEY: Key = DEFAULT_KEYBINDINGS.esc.key; #[test] - fn test_edit_artist_prompt_esc() { + fn test_edit_artist_confirm_prompt_prompt_confirmation_submit() { let mut app = App::test_default(); + let mut edit_artist = EditArtistModal { + tags: "usenet, testing".to_owned().into(), + path: "/nfs/Test Path".to_owned().into(), + monitored: Some(false), + ..EditArtistModal::default() + }; + edit_artist + .quality_profile_list + .set_items(vec!["Lossless".to_owned(), "HD - 1080p".to_owned()]); + edit_artist + .metadata_profile_list + .set_items(vec!["Standard".to_owned(), "Full".to_owned()]); + edit_artist + .monitor_list + .set_items(Vec::from_iter(NewItemMonitorType::iter())); + app.data.lidarr_data.edit_artist_modal = Some(edit_artist); + app.data.lidarr_data.artists.set_items(vec![Artist { + monitored: false, + ..Artist::default() + }]); + app.data.lidarr_data.quality_profile_map = BiMap::from_iter([ + (1111, "Lossless".to_owned()), + (2222, "HD - 1080p".to_owned()), + ]); + app.data.lidarr_data.metadata_profile_map = + BiMap::from_iter([(1111, "Standard".to_owned()), (2222, "Full".to_owned())]); + let expected_edit_artist_params = EditArtistParams { + artist_id: 0, + monitored: Some(false), + monitor_new_items: Some(NewItemMonitorType::All), + quality_profile_id: Some(1111), + metadata_profile_id: Some(1111), + root_folder_path: Some("/nfs/Test Path".to_owned()), + tag_input_string: Some("usenet, testing".to_owned()), + ..EditArtistParams::default() + }; + app.push_navigation_stack(ActiveLidarrBlock::Artists.into()); + app.push_navigation_stack(ActiveLidarrBlock::EditArtistPrompt.into()); + app.data.lidarr_data.prompt_confirm = true; + app.data.lidarr_data.selected_block = BlockSelectionState::new(EDIT_ARTIST_SELECTION_BLOCKS); + app + .data + .lidarr_data + .selected_block + .set_index(0, EDIT_ARTIST_SELECTION_BLOCKS.len() - 1); + + EditArtistHandler::new( + SUBMIT_KEY, + &mut app, + ActiveLidarrBlock::EditArtistPrompt, + None, + ) + .handle(); + + assert_navigation_popped!(app, ActiveLidarrBlock::Artists.into()); + assert_eq!( + app.data.lidarr_data.prompt_confirm_action, + Some(LidarrEvent::EditArtist(expected_edit_artist_params)) + ); + assert_modal_absent!(app.data.lidarr_data.edit_artist_modal); + assert!(app.should_refresh); + } + + #[test] + fn test_edit_artist_confirm_prompt_prompt_confirmation_submit_no_op_when_not_ready() { + let mut app = App::test_default(); + app.is_loading = true; app.data.lidarr_data.edit_artist_modal = Some(EditArtistModal::default()); app.push_navigation_stack(ActiveLidarrBlock::Artists.into()); app.push_navigation_stack(ActiveLidarrBlock::EditArtistPrompt.into()); app.data.lidarr_data.prompt_confirm = true; - EditArtistHandler::new(ESC_KEY, &mut app, ActiveLidarrBlock::EditArtistPrompt, None).handle(); - - assert_navigation_popped!(&app, ActiveLidarrBlock::Artists.into()); - assert!(app.data.lidarr_data.edit_artist_modal.is_none()); - assert!(!app.data.lidarr_data.prompt_confirm); - } - - #[test] - fn test_edit_artist_select_blocks_esc() { - let mut app = App::test_default(); - app.data.lidarr_data.edit_artist_modal = Some(EditArtistModal::default()); - app.push_navigation_stack(ActiveLidarrBlock::Artists.into()); - app.push_navigation_stack(ActiveLidarrBlock::EditArtistPrompt.into()); - app.push_navigation_stack(ActiveLidarrBlock::EditArtistSelectQualityProfile.into()); - EditArtistHandler::new( - ESC_KEY, + SUBMIT_KEY, &mut app, - ActiveLidarrBlock::EditArtistSelectQualityProfile, + ActiveLidarrBlock::EditArtistPrompt, None, ) .handle(); - assert_navigation_popped!(&app, ActiveLidarrBlock::EditArtistPrompt.into()); + assert_eq!( + app.get_current_route(), + ActiveLidarrBlock::EditArtistPrompt.into() + ); + assert_none!(app.data.lidarr_data.prompt_confirm_action); + assert!(!app.should_refresh); + } + + #[test] + fn test_edit_artist_toggle_monitored_submit() { + let current_route = Route::from(( + ActiveLidarrBlock::EditArtistPrompt, + Some(ActiveLidarrBlock::Artists), + )); + let mut app = App::test_default(); + app.data.lidarr_data.edit_artist_modal = Some(EditArtistModal::default()); + app.data.lidarr_data.selected_block = BlockSelectionState::new(EDIT_ARTIST_SELECTION_BLOCKS); + app.push_navigation_stack(ActiveLidarrBlock::Artists.into()); + app.push_navigation_stack(current_route); + + EditArtistHandler::new( + SUBMIT_KEY, + &mut app, + ActiveLidarrBlock::EditArtistPrompt, + Some(ActiveLidarrBlock::Artists), + ) + .handle(); + + assert_eq!(app.get_current_route(), current_route); + assert_some_eq_x!( + app + .data + .lidarr_data + .edit_artist_modal + .as_ref() + .unwrap() + .monitored, + true + ); + + EditArtistHandler::new( + SUBMIT_KEY, + &mut app, + ActiveLidarrBlock::EditArtistPrompt, + Some(ActiveLidarrBlock::Artists), + ) + .handle(); + + assert_eq!(app.get_current_route(), current_route); + assert_some_eq_x!( + app + .data + .lidarr_data + .edit_artist_modal + .as_ref() + .unwrap() + .monitored, + false + ); + } + + #[rstest] + #[case(ActiveLidarrBlock::EditArtistSelectQualityProfile, 2)] + #[case(ActiveLidarrBlock::EditArtistSelectMetadataProfile, 3)] + #[case(ActiveLidarrBlock::EditArtistPathInput, 4)] + #[case(ActiveLidarrBlock::EditArtistTagsInput, 5)] + fn test_edit_artist_prompt_selected_block_submit( + #[case] selected_block: ActiveLidarrBlock, + #[case] y_index: usize, + ) { + let mut app = App::test_default(); + app.data.lidarr_data.edit_artist_modal = Some(EditArtistModal::default()); + app.push_navigation_stack(ActiveLidarrBlock::Artists.into()); + app.push_navigation_stack( + ( + ActiveLidarrBlock::EditArtistPrompt, + Some(ActiveLidarrBlock::Artists), + ) + .into(), + ); + app.data.lidarr_data.selected_block = BlockSelectionState::new(EDIT_ARTIST_SELECTION_BLOCKS); + app.data.lidarr_data.selected_block.set_index(0, y_index); + + EditArtistHandler::new( + SUBMIT_KEY, + &mut app, + ActiveLidarrBlock::EditArtistPrompt, + Some(ActiveLidarrBlock::Artists), + ) + .handle(); + + assert_navigation_pushed!( + app, + (selected_block, Some(ActiveLidarrBlock::Artists)).into() + ); + assert_none!(app.data.lidarr_data.prompt_confirm_action); + + if selected_block == ActiveLidarrBlock::EditArtistPathInput + || selected_block == ActiveLidarrBlock::EditArtistTagsInput + { + assert!(app.ignore_special_keys_for_textbox_input); + } + } + + #[rstest] + fn test_edit_artist_prompt_selected_block_submit_no_op_when_not_ready( + #[values(1, 2, 3, 4)] y_index: usize, + ) { + let mut app = App::test_default(); + app.is_loading = true; + app.data.lidarr_data.edit_artist_modal = Some(EditArtistModal::default()); + app.push_navigation_stack(ActiveLidarrBlock::Artists.into()); + app.push_navigation_stack( + ( + ActiveLidarrBlock::EditArtistPrompt, + Some(ActiveLidarrBlock::Artists), + ) + .into(), + ); + app.data.lidarr_data.selected_block = BlockSelectionState::new(EDIT_ARTIST_SELECTION_BLOCKS); + app.data.lidarr_data.selected_block.set_index(0, y_index); + + EditArtistHandler::new( + SUBMIT_KEY, + &mut app, + ActiveLidarrBlock::EditArtistPrompt, + Some(ActiveLidarrBlock::Artists), + ) + .handle(); + + assert_eq!( + app.get_current_route(), + ( + ActiveLidarrBlock::EditArtistPrompt, + Some(ActiveLidarrBlock::Artists), + ) + .into() + ); + assert_none!(app.data.lidarr_data.prompt_confirm_action); + assert!(!app.ignore_special_keys_for_textbox_input); + } + + #[rstest] + fn test_edit_artist_prompt_selecting_preferences_blocks_submit( + #[values( + ActiveLidarrBlock::EditArtistSelectMonitorNewItems, + ActiveLidarrBlock::EditArtistSelectQualityProfile, + ActiveLidarrBlock::EditArtistSelectMetadataProfile, + ActiveLidarrBlock::EditArtistPathInput, + ActiveLidarrBlock::EditArtistTagsInput + )] + active_lidarr_block: ActiveLidarrBlock, + ) { + let mut app = App::test_default(); + app.data.lidarr_data.edit_artist_modal = Some(EditArtistModal::default()); + app.push_navigation_stack(ActiveLidarrBlock::Artists.into()); + app.push_navigation_stack(ActiveLidarrBlock::EditArtistPrompt.into()); + app.push_navigation_stack(active_lidarr_block.into()); + + EditArtistHandler::new( + SUBMIT_KEY, + &mut app, + active_lidarr_block, + Some(ActiveLidarrBlock::Artists), + ) + .handle(); + + assert_navigation_popped!(app, ActiveLidarrBlock::EditArtistPrompt.into()); + + if active_lidarr_block == ActiveLidarrBlock::EditArtistPathInput + || active_lidarr_block == ActiveLidarrBlock::EditArtistTagsInput + { + assert!(!app.ignore_special_keys_for_textbox_input); + } + } + } + + mod test_handle_esc { + use crate::assert_navigation_popped; + use crate::models::servarr_data::lidarr::modals::EditArtistModal; + use rstest::rstest; + + use super::*; + + const ESC_KEY: Key = DEFAULT_KEYBINDINGS.esc.key; + + #[rstest] + fn test_edit_artist_input_esc( + #[values( + ActiveLidarrBlock::EditArtistTagsInput, + ActiveLidarrBlock::EditArtistPathInput + )] + active_lidarr_block: ActiveLidarrBlock, + ) { + let mut app = App::test_default(); + app.data.lidarr_data.edit_artist_modal = Some(EditArtistModal::default()); + app.ignore_special_keys_for_textbox_input = true; + app.push_navigation_stack(ActiveLidarrBlock::Artists.into()); + app.push_navigation_stack(ActiveLidarrBlock::EditArtistPrompt.into()); + app.push_navigation_stack(active_lidarr_block.into()); + + EditArtistHandler::new(ESC_KEY, &mut app, active_lidarr_block, None).handle(); + + assert!(!app.ignore_special_keys_for_textbox_input); + assert_navigation_popped!(app, ActiveLidarrBlock::EditArtistPrompt.into()); + } + + #[test] + fn test_edit_artist_prompt_esc() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveLidarrBlock::Artists.into()); + app.push_navigation_stack(ActiveLidarrBlock::EditArtistPrompt.into()); + app.data.lidarr_data.edit_artist_modal = Some(EditArtistModal::default()); + + EditArtistHandler::new(ESC_KEY, &mut app, ActiveLidarrBlock::EditArtistPrompt, None).handle(); + + assert_navigation_popped!(app, ActiveLidarrBlock::Artists.into()); + + assert_modal_absent!(app.data.lidarr_data.edit_artist_modal); + assert!(!app.data.lidarr_data.prompt_confirm); + } + + #[rstest] + fn test_edit_artist_esc( + #[values( + ActiveLidarrBlock::EditArtistSelectMonitorNewItems, + ActiveLidarrBlock::EditArtistSelectQualityProfile, + ActiveLidarrBlock::EditArtistSelectMetadataProfile + )] + active_lidarr_block: ActiveLidarrBlock, + #[values(true, false)] is_ready: bool, + ) { + let mut app = App::test_default(); + app.is_loading = is_ready; + app.data.lidarr_data.edit_artist_modal = Some(EditArtistModal::default()); + app.push_navigation_stack(ActiveLidarrBlock::Artists.into()); + app.push_navigation_stack(active_lidarr_block.into()); + + EditArtistHandler::new(ESC_KEY, &mut app, active_lidarr_block, None).handle(); + + assert_navigation_popped!(app, ActiveLidarrBlock::Artists.into()); + } + } + + mod test_handle_key_char { + use super::*; + use crate::{ + assert_navigation_popped, + models::{ + BlockSelectionState, + servarr_data::lidarr::{ + lidarr_data::EDIT_ARTIST_SELECTION_BLOCKS, modals::EditArtistModal, + }, + }, + }; + use pretty_assertions::assert_eq; + + #[test] + fn test_edit_artist_path_input_backspace() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveLidarrBlock::Artists.into()); + app.data.lidarr_data.edit_artist_modal = Some(EditArtistModal { + path: "Test".into(), + ..EditArtistModal::default() + }); + + EditArtistHandler::new( + DEFAULT_KEYBINDINGS.backspace.key, + &mut app, + ActiveLidarrBlock::EditArtistPathInput, + None, + ) + .handle(); + + assert_str_eq!( + app + .data + .lidarr_data + .edit_artist_modal + .as_ref() + .unwrap() + .path + .text, + "Tes" + ); + } + + #[test] + fn test_edit_artist_tags_input_backspace() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveLidarrBlock::Artists.into()); + app.data.lidarr_data.edit_artist_modal = Some(EditArtistModal { + tags: "Test".into(), + ..EditArtistModal::default() + }); + + EditArtistHandler::new( + DEFAULT_KEYBINDINGS.backspace.key, + &mut app, + ActiveLidarrBlock::EditArtistTagsInput, + None, + ) + .handle(); + + assert_str_eq!( + app + .data + .lidarr_data + .edit_artist_modal + .as_ref() + .unwrap() + .tags + .text, + "Tes" + ); + } + + #[test] + fn test_edit_artist_path_input_char_key() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveLidarrBlock::Artists.into()); + app.data.lidarr_data.edit_artist_modal = Some(EditArtistModal::default()); + + EditArtistHandler::new( + Key::Char('a'), + &mut app, + ActiveLidarrBlock::EditArtistPathInput, + None, + ) + .handle(); + + assert_str_eq!( + app + .data + .lidarr_data + .edit_artist_modal + .as_ref() + .unwrap() + .path + .text, + "a" + ); + } + + #[test] + fn test_edit_artist_tags_input_char_key() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveLidarrBlock::Artists.into()); + app.data.lidarr_data.edit_artist_modal = Some(EditArtistModal::default()); + + EditArtistHandler::new( + Key::Char('a'), + &mut app, + ActiveLidarrBlock::EditArtistTagsInput, + None, + ) + .handle(); + + assert_str_eq!( + app + .data + .lidarr_data + .edit_artist_modal + .as_ref() + .unwrap() + .tags + .text, + "a" + ); + } + + #[test] + fn test_edit_artist_confirm_prompt_prompt_confirm() { + let mut app = App::test_default(); + let mut edit_artist = EditArtistModal { + tags: "usenet, testing".to_owned().into(), + path: "/nfs/Test Path".to_owned().into(), + monitored: Some(false), + ..EditArtistModal::default() + }; + edit_artist + .quality_profile_list + .set_items(vec!["Lossless".to_owned(), "HD - 1080p".to_owned()]); + edit_artist + .metadata_profile_list + .set_items(vec!["Standard".to_owned(), "Full".to_owned()]); + edit_artist + .monitor_list + .set_items(Vec::from_iter(NewItemMonitorType::iter())); + app.data.lidarr_data.edit_artist_modal = Some(edit_artist); + app.data.lidarr_data.artists.set_items(vec![Artist { + monitored: false, + ..Artist::default() + }]); + app.data.lidarr_data.quality_profile_map = BiMap::from_iter([ + (1111, "Lossless".to_owned()), + (2222, "HD - 1080p".to_owned()), + ]); + app.data.lidarr_data.metadata_profile_map = + BiMap::from_iter([(1111, "Standard".to_owned()), (2222, "Full".to_owned())]); + let expected_edit_artist_params = EditArtistParams { + artist_id: 0, + monitored: Some(false), + monitor_new_items: Some(NewItemMonitorType::All), + quality_profile_id: Some(1111), + metadata_profile_id: Some(1111), + root_folder_path: Some("/nfs/Test Path".to_owned()), + tag_input_string: Some("usenet, testing".to_owned()), + ..EditArtistParams::default() + }; + app.push_navigation_stack(ActiveLidarrBlock::Artists.into()); + app.push_navigation_stack(ActiveLidarrBlock::EditArtistPrompt.into()); + app.data.lidarr_data.selected_block = BlockSelectionState::new(EDIT_ARTIST_SELECTION_BLOCKS); + app + .data + .lidarr_data + .selected_block + .set_index(0, EDIT_ARTIST_SELECTION_BLOCKS.len() - 1); + + EditArtistHandler::new( + DEFAULT_KEYBINDINGS.confirm.key, + &mut app, + ActiveLidarrBlock::EditArtistPrompt, + None, + ) + .handle(); + + assert_navigation_popped!(app, ActiveLidarrBlock::Artists.into()); + assert_eq!( + app.data.lidarr_data.prompt_confirm_action, + Some(LidarrEvent::EditArtist(expected_edit_artist_params)) + ); + assert_modal_absent!(app.data.lidarr_data.edit_artist_modal); + assert!(app.should_refresh); } } #[test] fn test_edit_artist_handler_accepts() { - let mut edit_artist_handler_blocks = Vec::new(); - for block in ActiveLidarrBlock::iter() { - if EditArtistHandler::accepts(block) { - edit_artist_handler_blocks.push(block); + ActiveLidarrBlock::iter().for_each(|active_lidarr_block| { + if EDIT_ARTIST_BLOCKS.contains(&active_lidarr_block) { + assert!(EditArtistHandler::accepts(active_lidarr_block)); + } else { + assert!(!EditArtistHandler::accepts(active_lidarr_block)); } - } + }); + } - assert_eq!(edit_artist_handler_blocks, EDIT_ARTIST_BLOCKS.to_vec()); + #[rstest] + fn test_edit_artist_handler_ignore_special_keys( + #[values(true, false)] ignore_special_keys_for_textbox_input: bool, + ) { + let mut app = App::test_default(); + app.ignore_special_keys_for_textbox_input = ignore_special_keys_for_textbox_input; + let handler = EditArtistHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveLidarrBlock::default(), + None, + ); + + assert_eq!( + handler.ignore_special_keys(), + ignore_special_keys_for_textbox_input + ); + } + + #[test] + fn test_build_edit_artist_params() { + let mut app = App::test_default(); + let mut edit_artist = EditArtistModal { + tags: "usenet, testing".to_owned().into(), + path: "/nfs/Test Path".to_owned().into(), + monitored: Some(false), + ..EditArtistModal::default() + }; + edit_artist + .quality_profile_list + .set_items(vec!["Lossless".to_owned(), "HD - 1080p".to_owned()]); + edit_artist + .metadata_profile_list + .set_items(vec!["Standard".to_owned(), "Full".to_owned()]); + edit_artist + .monitor_list + .set_items(Vec::from_iter(NewItemMonitorType::iter())); + app.data.lidarr_data.edit_artist_modal = Some(edit_artist); + app.data.lidarr_data.artists.set_items(vec![Artist { + monitored: false, + ..Artist::default() + }]); + app.data.lidarr_data.quality_profile_map = BiMap::from_iter([ + (1111, "Lossless".to_owned()), + (2222, "HD - 1080p".to_owned()), + ]); + app.data.lidarr_data.metadata_profile_map = + BiMap::from_iter([(1111, "Standard".to_owned()), (2222, "Full".to_owned())]); + let expected_edit_artist_params = EditArtistParams { + artist_id: 0, + monitored: Some(false), + monitor_new_items: Some(NewItemMonitorType::All), + quality_profile_id: Some(1111), + metadata_profile_id: Some(1111), + root_folder_path: Some("/nfs/Test Path".to_owned()), + tag_input_string: Some("usenet, testing".to_owned()), + ..EditArtistParams::default() + }; + + let edit_artist_params = EditArtistHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveLidarrBlock::EditArtistPrompt, + None, + ) + .build_edit_artist_params(); + + assert_eq!(edit_artist_params, expected_edit_artist_params); + assert_modal_absent!(app.data.lidarr_data.edit_artist_modal); } #[test] fn test_edit_artist_handler_is_not_ready_when_loading() { let mut app = App::test_default(); + app.push_navigation_stack(ActiveLidarrBlock::Artists.into()); app.is_loading = true; - app.data.lidarr_data.edit_artist_modal = Some(EditArtistModal::default()); let handler = EditArtistHandler::new( - Key::Esc, + DEFAULT_KEYBINDINGS.esc.key, &mut app, ActiveLidarrBlock::EditArtistPrompt, None, @@ -186,10 +1391,11 @@ mod tests { #[test] fn test_edit_artist_handler_is_not_ready_when_edit_artist_modal_is_none() { let mut app = App::test_default(); - app.data.lidarr_data.edit_artist_modal = None; + app.push_navigation_stack(ActiveLidarrBlock::Artists.into()); + app.is_loading = false; let handler = EditArtistHandler::new( - Key::Esc, + DEFAULT_KEYBINDINGS.esc.key, &mut app, ActiveLidarrBlock::EditArtistPrompt, None, @@ -199,12 +1405,14 @@ mod tests { } #[test] - fn test_edit_artist_handler_is_ready_when_not_loading_and_modal_is_some() { + fn test_edit_artist_handler_is_ready_when_edit_artist_modal_is_some() { let mut app = App::test_default(); + app.push_navigation_stack(ActiveLidarrBlock::Artists.into()); + app.is_loading = false; app.data.lidarr_data.edit_artist_modal = Some(EditArtistModal::default()); let handler = EditArtistHandler::new( - Key::Esc, + DEFAULT_KEYBINDINGS.esc.key, &mut app, ActiveLidarrBlock::EditArtistPrompt, None, diff --git a/src/handlers/lidarr_handlers/library/library_handler_tests.rs b/src/handlers/lidarr_handlers/library/library_handler_tests.rs index 4a7d94e..9801a8d 100644 --- a/src/handlers/lidarr_handlers/library/library_handler_tests.rs +++ b/src/handlers/lidarr_handlers/library/library_handler_tests.rs @@ -3,6 +3,7 @@ mod tests { use std::cmp::Ordering; use pretty_assertions::{assert_eq, assert_str_eq}; + use rstest::rstest; use serde_json::Number; use strum::IntoEnumIterator; @@ -11,9 +12,15 @@ mod tests { use crate::handlers::KeyEventHandler; use crate::handlers::lidarr_handlers::library::{LibraryHandler, artists_sorting_options}; use crate::models::lidarr_models::{Artist, ArtistStatistics, ArtistStatus}; - use crate::models::servarr_data::lidarr::lidarr_data::{ActiveLidarrBlock, DELETE_ARTIST_BLOCKS, EDIT_ARTIST_BLOCKS, LIBRARY_BLOCKS}; + use crate::models::servarr_data::lidarr::lidarr_data::{ + ActiveLidarrBlock, DELETE_ARTIST_BLOCKS, EDIT_ARTIST_BLOCKS, EDIT_ARTIST_SELECTION_BLOCKS, + LIBRARY_BLOCKS, + }; + use crate::models::servarr_data::lidarr::modals::EditArtistModal; use crate::network::lidarr_network::LidarrEvent; - use crate::{assert_modal_absent, assert_navigation_popped, assert_navigation_pushed}; + use crate::{ + assert_modal_absent, assert_modal_present, assert_navigation_popped, assert_navigation_pushed, + }; #[test] fn test_library_handler_accepts() { @@ -22,7 +29,7 @@ mod tests { library_handler_blocks.extend(DELETE_ARTIST_BLOCKS); library_handler_blocks.extend(EDIT_ARTIST_BLOCKS); - ActiveLidarrBlock::iter().for_each(|lidarr_block| { + ActiveLidarrBlock::iter().for_each(|lidarr_block| { if library_handler_blocks.contains(&lidarr_block) { assert!(LibraryHandler::accepts(lidarr_block)); } else { @@ -494,4 +501,134 @@ mod tests { }, ] } + + #[test] + fn test_delegates_delete_artist_blocks_to_delete_artist_handler() { + let mut app = App::test_default(); + app + .data + .lidarr_data + .artists + .set_items(vec![Artist::default()]); + app.push_navigation_stack(ActiveLidarrBlock::Artists.into()); + app.push_navigation_stack(ActiveLidarrBlock::DeleteArtistPrompt.into()); + + LibraryHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveLidarrBlock::DeleteArtistPrompt, + None, + ) + .handle(); + + assert_eq!(app.get_current_route(), ActiveLidarrBlock::Artists.into()); + } + + #[rstest] + fn test_delegates_edit_artist_blocks_to_edit_artist_handler( + #[values( + ActiveLidarrBlock::EditArtistPrompt, + ActiveLidarrBlock::EditArtistSelectMetadataProfile, + ActiveLidarrBlock::EditArtistSelectMonitorNewItems, + ActiveLidarrBlock::EditArtistSelectQualityProfile, + ActiveLidarrBlock::EditArtistTagsInput, + ActiveLidarrBlock::EditArtistPathInput, + )] + active_lidarr_block: ActiveLidarrBlock, + ) { + let mut app = App::test_default(); + app + .data + .lidarr_data + .artists + .set_items(vec![Artist::default()]); + app.data.lidarr_data.edit_artist_modal = Some(EditArtistModal::default()); + app.push_navigation_stack(ActiveLidarrBlock::Artists.into()); + app.push_navigation_stack(active_lidarr_block.into()); + + LibraryHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + active_lidarr_block, + None, + ) + .handle(); + + assert_eq!(app.get_current_route(), ActiveLidarrBlock::Artists.into()); + } + + #[test] + fn test_edit_key() { + let mut app = App::test_default(); + app + .data + .lidarr_data + .artists + .set_items(vec![Artist::default()]); + app.data.lidarr_data.quality_profile_map = + bimap::BiMap::from_iter([(0i64, "Default Quality".to_owned())]); + app.data.lidarr_data.metadata_profile_map = + bimap::BiMap::from_iter([(0i64, "Default Metadata".to_owned())]); + app.push_navigation_stack(ActiveLidarrBlock::Artists.into()); + + LibraryHandler::new( + DEFAULT_KEYBINDINGS.edit.key, + &mut app, + ActiveLidarrBlock::Artists, + None, + ) + .handle(); + + assert_navigation_pushed!(app, ActiveLidarrBlock::EditArtistPrompt.into()); + assert_modal_present!(app.data.lidarr_data.edit_artist_modal); + assert_eq!( + app.data.lidarr_data.selected_block.blocks, + EDIT_ARTIST_SELECTION_BLOCKS + ); + } + + #[test] + fn test_edit_key_no_op_when_not_ready() { + let mut app = App::test_default(); + app.is_loading = true; + app.push_navigation_stack(ActiveLidarrBlock::Artists.into()); + app + .data + .lidarr_data + .artists + .set_items(vec![Artist::default()]); + + LibraryHandler::new( + DEFAULT_KEYBINDINGS.edit.key, + &mut app, + ActiveLidarrBlock::Artists, + None, + ) + .handle(); + + assert_eq!(app.get_current_route(), ActiveLidarrBlock::Artists.into()); + assert_modal_absent!(app.data.lidarr_data.edit_artist_modal); + } + + #[test] + fn test_refresh_key() { + let mut app = App::test_default(); + app + .data + .lidarr_data + .artists + .set_items(vec![Artist::default()]); + app.push_navigation_stack(ActiveLidarrBlock::Artists.into()); + + LibraryHandler::new( + DEFAULT_KEYBINDINGS.refresh.key, + &mut app, + ActiveLidarrBlock::Artists, + None, + ) + .handle(); + + assert_eq!(app.get_current_route(), ActiveLidarrBlock::Artists.into()); + assert!(app.should_refresh); + } } diff --git a/src/handlers/lidarr_handlers/library/mod.rs b/src/handlers/lidarr_handlers/library/mod.rs index 114442d..fdbf9c2 100644 --- a/src/handlers/lidarr_handlers/library/mod.rs +++ b/src/handlers/lidarr_handlers/library/mod.rs @@ -10,7 +10,6 @@ use crate::{ ActiveLidarrBlock, DELETE_ARTIST_SELECTION_BLOCKS, EDIT_ARTIST_SELECTION_BLOCKS, LIBRARY_BLOCKS, }, - servarr_data::lidarr::modals::EditArtistModal, stateful_table::SortOption, }, network::lidarr_network::LidarrEvent, @@ -22,9 +21,9 @@ use crate::handlers::table_handler::{TableHandlingConfig, handle_table}; mod delete_artist_handler; mod edit_artist_handler; +use crate::models::Route; pub(in crate::handlers::lidarr_handlers) use delete_artist_handler::DeleteArtistHandler; pub(in crate::handlers::lidarr_handlers) use edit_artist_handler::EditArtistHandler; -use crate::models::Route; #[cfg(test)] #[path = "library_handler_tests.rs"] @@ -66,7 +65,8 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveLidarrBlock> for LibraryHandler<'a, ' .handle(); } _ if EditArtistHandler::accepts(self.active_lidarr_block) => { - EditArtistHandler::new(self.key, self.app, self.active_lidarr_block, self.context).handle(); + EditArtistHandler::new(self.key, self.app, self.active_lidarr_block, self.context) + .handle(); } _ => self.handle_key_event(), } @@ -168,8 +168,7 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveLidarrBlock> for LibraryHandler<'a, ' .pop_and_push_navigation_stack(self.active_lidarr_block.into()); } _ if matches_key!(edit, key) => { - self.app.data.lidarr_data.edit_artist_modal = - Some((&self.app.data.lidarr_data).into()); + self.app.data.lidarr_data.edit_artist_modal = Some((&self.app.data.lidarr_data).into()); self .app .push_navigation_stack(ActiveLidarrBlock::EditArtistPrompt.into()); diff --git a/src/handlers/lidarr_handlers/lidarr_handler_tests.rs b/src/handlers/lidarr_handlers/lidarr_handler_tests.rs index c180d35..484d367 100644 --- a/src/handlers/lidarr_handlers/lidarr_handler_tests.rs +++ b/src/handlers/lidarr_handlers/lidarr_handler_tests.rs @@ -1,12 +1,15 @@ #[cfg(test)] mod tests { - use rstest::rstest; - use strum::IntoEnumIterator; use crate::app::App; use crate::app::key_binding::DEFAULT_KEYBINDINGS; use crate::handlers::KeyEventHandler; use crate::handlers::lidarr_handlers::LidarrHandler; + use crate::models::lidarr_models::Artist; use crate::models::servarr_data::lidarr::lidarr_data::ActiveLidarrBlock; + use crate::models::servarr_data::lidarr::modals::EditArtistModal; + use pretty_assertions::assert_eq; + use rstest::rstest; + use strum::IntoEnumIterator; #[rstest] fn test_lidarr_handler_ignore_special_keys( @@ -48,4 +51,45 @@ mod tests { assert!(LidarrHandler::accepts(lidarr_block)); } } + + #[rstest] + fn test_delegates_library_blocks_to_library_handler( + #[values( + ActiveLidarrBlock::Artists, + ActiveLidarrBlock::ArtistsSortPrompt, + ActiveLidarrBlock::FilterArtists, + ActiveLidarrBlock::FilterArtistsError, + ActiveLidarrBlock::SearchArtists, + ActiveLidarrBlock::SearchArtistsError, + ActiveLidarrBlock::UpdateAllArtistsPrompt, + ActiveLidarrBlock::DeleteArtistPrompt, + ActiveLidarrBlock::EditArtistPrompt, + ActiveLidarrBlock::EditArtistPathInput, + ActiveLidarrBlock::EditArtistSelectMetadataProfile, + ActiveLidarrBlock::EditArtistSelectMonitorNewItems, + ActiveLidarrBlock::EditArtistSelectQualityProfile, + ActiveLidarrBlock::EditArtistTagsInput + )] + active_lidarr_block: ActiveLidarrBlock, + ) { + let mut app = App::test_default(); + app + .data + .lidarr_data + .artists + .set_items(vec![Artist::default()]); + app.data.lidarr_data.edit_artist_modal = Some(EditArtistModal::default()); + app.push_navigation_stack(ActiveLidarrBlock::Artists.into()); + app.push_navigation_stack(active_lidarr_block.into()); + + LidarrHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + active_lidarr_block, + None, + ) + .handle(); + + assert_eq!(app.get_current_route(), ActiveLidarrBlock::Artists.into()); + } } diff --git a/src/handlers/lidarr_handlers/mod.rs b/src/handlers/lidarr_handlers/mod.rs index 16ff15b..04c7499 100644 --- a/src/handlers/lidarr_handlers/mod.rs +++ b/src/handlers/lidarr_handlers/mod.rs @@ -1,10 +1,10 @@ use library::LibraryHandler; +use super::KeyEventHandler; +use crate::models::Route; use crate::{ app::App, event::Key, matches_key, models::servarr_data::lidarr::lidarr_data::ActiveLidarrBlock, }; -use crate::models::Route; -use super::KeyEventHandler; mod library; diff --git a/src/handlers/radarr_handlers/blocklist/mod.rs b/src/handlers/radarr_handlers/blocklist/mod.rs index 44dcbbb..3199c38 100644 --- a/src/handlers/radarr_handlers/blocklist/mod.rs +++ b/src/handlers/radarr_handlers/blocklist/mod.rs @@ -4,8 +4,8 @@ use crate::handlers::radarr_handlers::handle_change_tab_left_right_keys; use crate::handlers::table_handler::{TableHandlingConfig, handle_table}; use crate::handlers::{KeyEventHandler, handle_clear_errors, handle_prompt_toggle}; use crate::matches_key; -use crate::models::radarr_models::BlocklistItem; use crate::models::Route; +use crate::models::radarr_models::BlocklistItem; use crate::models::servarr_data::radarr::radarr_data::{ActiveRadarrBlock, BLOCKLIST_BLOCKS}; use crate::models::stateful_table::SortOption; use crate::network::radarr_network::RadarrEvent; diff --git a/src/handlers/radarr_handlers/collections/collection_details_handler.rs b/src/handlers/radarr_handlers/collections/collection_details_handler.rs index 5fb64a2..92ca215 100644 --- a/src/handlers/radarr_handlers/collections/collection_details_handler.rs +++ b/src/handlers/radarr_handlers/collections/collection_details_handler.rs @@ -3,12 +3,12 @@ use crate::event::Key; use crate::handlers::KeyEventHandler; use crate::handlers::table_handler::{TableHandlingConfig, handle_table}; use crate::matches_key; -use crate::models::{BlockSelectionState, Route}; use crate::models::servarr_data::radarr::radarr_data::{ ADD_MOVIE_SELECTION_BLOCKS, ActiveRadarrBlock, COLLECTION_DETAILS_BLOCKS, EDIT_COLLECTION_SELECTION_BLOCKS, }; use crate::models::stateful_table::StatefulTable; +use crate::models::{BlockSelectionState, Route}; #[cfg(test)] #[path = "collection_details_handler_tests.rs"] diff --git a/src/handlers/radarr_handlers/collections/edit_collection_handler.rs b/src/handlers/radarr_handlers/collections/edit_collection_handler.rs index 1f38ce5..fe34986 100644 --- a/src/handlers/radarr_handlers/collections/edit_collection_handler.rs +++ b/src/handlers/radarr_handlers/collections/edit_collection_handler.rs @@ -1,10 +1,10 @@ use crate::app::App; use crate::event::Key; use crate::handlers::{KeyEventHandler, handle_prompt_toggle}; -use crate::models::{Route, Scrollable}; use crate::models::radarr_models::EditCollectionParams; use crate::models::servarr_data::radarr::modals::EditCollectionModal; use crate::models::servarr_data::radarr::radarr_data::{ActiveRadarrBlock, EDIT_COLLECTION_BLOCKS}; +use crate::models::{Route, Scrollable}; use crate::network::radarr_network::RadarrEvent; use crate::{handle_text_box_keys, handle_text_box_left_right_keys, matches_key}; diff --git a/src/handlers/radarr_handlers/collections/mod.rs b/src/handlers/radarr_handlers/collections/mod.rs index bcc2370..4e2e32a 100644 --- a/src/handlers/radarr_handlers/collections/mod.rs +++ b/src/handlers/radarr_handlers/collections/mod.rs @@ -6,12 +6,12 @@ use crate::handlers::radarr_handlers::handle_change_tab_left_right_keys; use crate::handlers::table_handler::{TableHandlingConfig, handle_table}; use crate::handlers::{KeyEventHandler, handle_clear_errors, handle_prompt_toggle}; use crate::matches_key; -use crate::models::{BlockSelectionState, Route}; use crate::models::radarr_models::Collection; use crate::models::servarr_data::radarr::radarr_data::{ ActiveRadarrBlock, COLLECTIONS_BLOCKS, EDIT_COLLECTION_SELECTION_BLOCKS, }; use crate::models::stateful_table::SortOption; +use crate::models::{BlockSelectionState, Route}; use crate::network::radarr_network::RadarrEvent; mod collection_details_handler; diff --git a/src/handlers/radarr_handlers/indexers/edit_indexer_handler.rs b/src/handlers/radarr_handlers/indexers/edit_indexer_handler.rs index 1c224c3..c22dad1 100644 --- a/src/handlers/radarr_handlers/indexers/edit_indexer_handler.rs +++ b/src/handlers/radarr_handlers/indexers/edit_indexer_handler.rs @@ -1,6 +1,7 @@ use crate::app::App; use crate::event::Key; use crate::handlers::{KeyEventHandler, handle_prompt_toggle}; +use crate::models::Route; use crate::models::servarr_data::modals::EditIndexerModal; use crate::models::servarr_data::radarr::radarr_data::{ActiveRadarrBlock, EDIT_INDEXER_BLOCKS}; use crate::models::servarr_models::EditIndexerParams; @@ -8,7 +9,6 @@ use crate::network::radarr_network::RadarrEvent; use crate::{ handle_prompt_left_right_keys, handle_text_box_keys, handle_text_box_left_right_keys, matches_key, }; -use crate::models::Route; #[cfg(test)] #[path = "edit_indexer_handler_tests.rs"] diff --git a/src/handlers/radarr_handlers/indexers/edit_indexer_settings_handler.rs b/src/handlers/radarr_handlers/indexers/edit_indexer_settings_handler.rs index f237f97..9145529 100644 --- a/src/handlers/radarr_handlers/indexers/edit_indexer_settings_handler.rs +++ b/src/handlers/radarr_handlers/indexers/edit_indexer_settings_handler.rs @@ -1,6 +1,7 @@ use crate::app::App; use crate::event::Key; use crate::handlers::{KeyEventHandler, handle_prompt_toggle}; +use crate::models::Route; use crate::models::radarr_models::IndexerSettings; use crate::models::servarr_data::radarr::radarr_data::{ ActiveRadarrBlock, INDEXER_SETTINGS_BLOCKS, @@ -9,7 +10,6 @@ use crate::network::radarr_network::RadarrEvent; use crate::{ handle_prompt_left_right_keys, handle_text_box_keys, handle_text_box_left_right_keys, matches_key, }; -use crate::models::Route; #[cfg(test)] #[path = "edit_indexer_settings_handler_tests.rs"] diff --git a/src/handlers/radarr_handlers/indexers/mod.rs b/src/handlers/radarr_handlers/indexers/mod.rs index c37d9f6..72b99e8 100644 --- a/src/handlers/radarr_handlers/indexers/mod.rs +++ b/src/handlers/radarr_handlers/indexers/mod.rs @@ -7,11 +7,11 @@ use crate::handlers::radarr_handlers::indexers::test_all_indexers_handler::TestA use crate::handlers::table_handler::{TableHandlingConfig, handle_table}; use crate::handlers::{KeyEventHandler, handle_clear_errors, handle_prompt_toggle}; use crate::matches_key; -use crate::models::{BlockSelectionState, Route}; use crate::models::servarr_data::radarr::radarr_data::{ ActiveRadarrBlock, EDIT_INDEXER_NZB_SELECTION_BLOCKS, EDIT_INDEXER_TORRENT_SELECTION_BLOCKS, INDEXER_SETTINGS_SELECTION_BLOCKS, INDEXERS_BLOCKS, }; +use crate::models::{BlockSelectionState, Route}; use crate::network::radarr_network::RadarrEvent; mod edit_indexer_handler; diff --git a/src/handlers/radarr_handlers/library/delete_movie_handler.rs b/src/handlers/radarr_handlers/library/delete_movie_handler.rs index ab25e4c..c3817c2 100644 --- a/src/handlers/radarr_handlers/library/delete_movie_handler.rs +++ b/src/handlers/radarr_handlers/library/delete_movie_handler.rs @@ -2,8 +2,8 @@ use crate::app::App; use crate::event::Key; use crate::handlers::{KeyEventHandler, handle_prompt_toggle}; use crate::matches_key; -use crate::models::radarr_models::DeleteMovieParams; use crate::models::Route; +use crate::models::radarr_models::DeleteMovieParams; use crate::models::servarr_data::radarr::radarr_data::{ActiveRadarrBlock, DELETE_MOVIE_BLOCKS}; use crate::network::radarr_network::RadarrEvent; diff --git a/src/handlers/radarr_handlers/library/edit_movie_handler.rs b/src/handlers/radarr_handlers/library/edit_movie_handler.rs index db5e4b7..593f08d 100644 --- a/src/handlers/radarr_handlers/library/edit_movie_handler.rs +++ b/src/handlers/radarr_handlers/library/edit_movie_handler.rs @@ -1,10 +1,10 @@ use crate::app::App; use crate::event::Key; use crate::handlers::{KeyEventHandler, handle_prompt_toggle}; -use crate::models::{Route, Scrollable}; use crate::models::radarr_models::EditMovieParams; use crate::models::servarr_data::radarr::modals::EditMovieModal; use crate::models::servarr_data::radarr::radarr_data::{ActiveRadarrBlock, EDIT_MOVIE_BLOCKS}; +use crate::models::{Route, Scrollable}; use crate::network::radarr_network::RadarrEvent; use crate::{handle_text_box_keys, handle_text_box_left_right_keys, matches_key}; diff --git a/src/handlers/radarr_handlers/mod.rs b/src/handlers/radarr_handlers/mod.rs index a785c41..282ce3b 100644 --- a/src/handlers/radarr_handlers/mod.rs +++ b/src/handlers/radarr_handlers/mod.rs @@ -6,9 +6,9 @@ use crate::handlers::radarr_handlers::indexers::IndexersHandler; use crate::handlers::radarr_handlers::library::LibraryHandler; use crate::handlers::radarr_handlers::root_folders::RootFoldersHandler; use crate::handlers::radarr_handlers::system::SystemHandler; +use crate::models::Route; use crate::models::servarr_data::radarr::radarr_data::ActiveRadarrBlock; use crate::{App, Key, matches_key}; -use crate::models::Route; mod blocklist; mod collections; diff --git a/src/handlers/radarr_handlers/root_folders/mod.rs b/src/handlers/radarr_handlers/root_folders/mod.rs index 27a3bd4..eadd21d 100644 --- a/src/handlers/radarr_handlers/root_folders/mod.rs +++ b/src/handlers/radarr_handlers/root_folders/mod.rs @@ -3,9 +3,9 @@ use crate::event::Key; use crate::handlers::radarr_handlers::handle_change_tab_left_right_keys; use crate::handlers::table_handler::{TableHandlingConfig, handle_table}; use crate::handlers::{KeyEventHandler, handle_clear_errors, handle_prompt_toggle}; -use crate::models::{HorizontallyScrollableText, Route}; use crate::models::servarr_data::radarr::radarr_data::{ActiveRadarrBlock, ROOT_FOLDERS_BLOCKS}; use crate::models::servarr_models::AddRootFolderBody; +use crate::models::{HorizontallyScrollableText, Route}; use crate::network::radarr_network::RadarrEvent; use crate::{handle_text_box_keys, handle_text_box_left_right_keys, matches_key}; diff --git a/src/handlers/radarr_handlers/system/mod.rs b/src/handlers/radarr_handlers/system/mod.rs index bf3cc56..8bc1778 100644 --- a/src/handlers/radarr_handlers/system/mod.rs +++ b/src/handlers/radarr_handlers/system/mod.rs @@ -4,8 +4,8 @@ use crate::handlers::radarr_handlers::handle_change_tab_left_right_keys; use crate::handlers::radarr_handlers::system::system_details_handler::SystemDetailsHandler; use crate::handlers::{KeyEventHandler, handle_clear_errors}; use crate::matches_key; -use crate::models::{Route, Scrollable}; use crate::models::servarr_data::radarr::radarr_data::ActiveRadarrBlock; +use crate::models::{Route, Scrollable}; mod system_details_handler; diff --git a/src/handlers/radarr_handlers/system/system_details_handler.rs b/src/handlers/radarr_handlers/system/system_details_handler.rs index 60db4c7..77b553c 100644 --- a/src/handlers/radarr_handlers/system/system_details_handler.rs +++ b/src/handlers/radarr_handlers/system/system_details_handler.rs @@ -2,10 +2,10 @@ use crate::app::App; use crate::event::Key; use crate::handlers::{KeyEventHandler, handle_prompt_toggle}; use crate::matches_key; -use crate::models::{Route, Scrollable}; use crate::models::radarr_models::RadarrTaskName; use crate::models::servarr_data::radarr::radarr_data::{ActiveRadarrBlock, SYSTEM_DETAILS_BLOCKS}; use crate::models::stateful_list::StatefulList; +use crate::models::{Route, Scrollable}; use crate::network::radarr_network::RadarrEvent; #[cfg(test)] diff --git a/src/handlers/sonarr_handlers/indexers/edit_indexer_handler.rs b/src/handlers/sonarr_handlers/indexers/edit_indexer_handler.rs index c6613aa..4e2d3c9 100644 --- a/src/handlers/sonarr_handlers/indexers/edit_indexer_handler.rs +++ b/src/handlers/sonarr_handlers/indexers/edit_indexer_handler.rs @@ -1,6 +1,7 @@ use crate::app::App; use crate::event::Key; use crate::handlers::{KeyEventHandler, handle_prompt_toggle}; +use crate::models::Route; use crate::models::servarr_data::modals::EditIndexerModal; use crate::models::servarr_data::sonarr::sonarr_data::{ActiveSonarrBlock, EDIT_INDEXER_BLOCKS}; use crate::models::servarr_models::EditIndexerParams; @@ -8,7 +9,6 @@ use crate::network::sonarr_network::SonarrEvent; use crate::{ handle_prompt_left_right_keys, handle_text_box_keys, handle_text_box_left_right_keys, matches_key, }; -use crate::models::Route; #[cfg(test)] #[path = "edit_indexer_handler_tests.rs"] diff --git a/src/handlers/sonarr_handlers/indexers/edit_indexer_settings_handler.rs b/src/handlers/sonarr_handlers/indexers/edit_indexer_settings_handler.rs index 22d0ea8..e2185ff 100644 --- a/src/handlers/sonarr_handlers/indexers/edit_indexer_settings_handler.rs +++ b/src/handlers/sonarr_handlers/indexers/edit_indexer_settings_handler.rs @@ -1,13 +1,13 @@ use crate::app::App; use crate::event::Key; use crate::handlers::{KeyEventHandler, handle_prompt_toggle}; +use crate::models::Route; use crate::models::servarr_data::sonarr::sonarr_data::{ ActiveSonarrBlock, INDEXER_SETTINGS_BLOCKS, }; use crate::models::sonarr_models::IndexerSettings; use crate::network::sonarr_network::SonarrEvent; use crate::{handle_prompt_left_right_keys, matches_key}; -use crate::models::Route; #[cfg(test)] #[path = "edit_indexer_settings_handler_tests.rs"] diff --git a/src/handlers/sonarr_handlers/indexers/mod.rs b/src/handlers/sonarr_handlers/indexers/mod.rs index 3d3cd46..cc8e852 100644 --- a/src/handlers/sonarr_handlers/indexers/mod.rs +++ b/src/handlers/sonarr_handlers/indexers/mod.rs @@ -7,11 +7,11 @@ use crate::handlers::sonarr_handlers::indexers::test_all_indexers_handler::TestA use crate::handlers::table_handler::{TableHandlingConfig, handle_table}; use crate::handlers::{KeyEventHandler, handle_clear_errors, handle_prompt_toggle}; use crate::matches_key; -use crate::models::{BlockSelectionState, Route}; use crate::models::servarr_data::sonarr::sonarr_data::{ ActiveSonarrBlock, EDIT_INDEXER_NZB_SELECTION_BLOCKS, EDIT_INDEXER_TORRENT_SELECTION_BLOCKS, INDEXER_SETTINGS_SELECTION_BLOCKS, INDEXERS_BLOCKS, }; +use crate::models::{BlockSelectionState, Route}; use crate::network::sonarr_network::SonarrEvent; mod edit_indexer_handler; diff --git a/src/handlers/sonarr_handlers/library/delete_series_handler.rs b/src/handlers/sonarr_handlers/library/delete_series_handler.rs index 6b6dac5..479545f 100644 --- a/src/handlers/sonarr_handlers/library/delete_series_handler.rs +++ b/src/handlers/sonarr_handlers/library/delete_series_handler.rs @@ -1,3 +1,4 @@ +use crate::models::Route; use crate::models::sonarr_models::DeleteSeriesParams; use crate::network::sonarr_network::SonarrEvent; use crate::{ @@ -7,7 +8,6 @@ use crate::{ matches_key, models::servarr_data::sonarr::sonarr_data::{ActiveSonarrBlock, DELETE_SERIES_BLOCKS}, }; -use crate::models::Route; #[cfg(test)] #[path = "delete_series_handler_tests.rs"] diff --git a/src/handlers/sonarr_handlers/library/edit_series_handler.rs b/src/handlers/sonarr_handlers/library/edit_series_handler.rs index 7e872d8..4bd4d79 100644 --- a/src/handlers/sonarr_handlers/library/edit_series_handler.rs +++ b/src/handlers/sonarr_handlers/library/edit_series_handler.rs @@ -1,10 +1,10 @@ use crate::app::App; use crate::event::Key; use crate::handlers::{KeyEventHandler, handle_prompt_toggle}; -use crate::models::{Route, Scrollable}; use crate::models::servarr_data::sonarr::modals::EditSeriesModal; use crate::models::servarr_data::sonarr::sonarr_data::{ActiveSonarrBlock, EDIT_SERIES_BLOCKS}; use crate::models::sonarr_models::EditSeriesParams; +use crate::models::{Route, Scrollable}; use crate::network::sonarr_network::SonarrEvent; use crate::{handle_text_box_keys, handle_text_box_left_right_keys, matches_key}; diff --git a/src/handlers/sonarr_handlers/library/season_details_handler.rs b/src/handlers/sonarr_handlers/library/season_details_handler.rs index 2d4b2e1..903351d 100644 --- a/src/handlers/sonarr_handlers/library/season_details_handler.rs +++ b/src/handlers/sonarr_handlers/library/season_details_handler.rs @@ -4,6 +4,7 @@ use crate::handlers::sonarr_handlers::history::history_sorting_options; use crate::handlers::table_handler::{TableHandlingConfig, handle_table}; use crate::handlers::{KeyEventHandler, handle_prompt_toggle}; use crate::matches_key; +use crate::models::Route; use crate::models::servarr_data::sonarr::sonarr_data::{ActiveSonarrBlock, SEASON_DETAILS_BLOCKS}; use crate::models::servarr_models::Language; use crate::models::sonarr_models::{ @@ -12,7 +13,6 @@ use crate::models::sonarr_models::{ use crate::models::stateful_table::SortOption; use crate::network::sonarr_network::SonarrEvent; use serde_json::Number; -use crate::models::Route; #[cfg(test)] #[path = "season_details_handler_tests.rs"] diff --git a/src/handlers/sonarr_handlers/library/series_details_handler.rs b/src/handlers/sonarr_handlers/library/series_details_handler.rs index 478ef3f..c089bff 100644 --- a/src/handlers/sonarr_handlers/library/series_details_handler.rs +++ b/src/handlers/sonarr_handlers/library/series_details_handler.rs @@ -4,11 +4,11 @@ use crate::handlers::sonarr_handlers::history::history_sorting_options; use crate::handlers::table_handler::{TableHandlingConfig, handle_table}; use crate::handlers::{KeyEventHandler, handle_prompt_toggle}; use crate::matches_key; -use crate::models::{BlockSelectionState, Route}; use crate::models::servarr_data::sonarr::sonarr_data::{ ActiveSonarrBlock, EDIT_SERIES_SELECTION_BLOCKS, SERIES_DETAILS_BLOCKS, }; use crate::models::sonarr_models::{Season, SonarrHistoryItem}; +use crate::models::{BlockSelectionState, Route}; use crate::network::sonarr_network::SonarrEvent; #[cfg(test)] diff --git a/src/handlers/sonarr_handlers/mod.rs b/src/handlers/sonarr_handlers/mod.rs index e33eecb..ebd139d 100644 --- a/src/handlers/sonarr_handlers/mod.rs +++ b/src/handlers/sonarr_handlers/mod.rs @@ -6,11 +6,11 @@ use library::LibraryHandler; use root_folders::RootFoldersHandler; use system::SystemHandler; +use super::KeyEventHandler; +use crate::models::Route; use crate::{ app::App, event::Key, matches_key, models::servarr_data::sonarr::sonarr_data::ActiveSonarrBlock, }; -use crate::models::Route; -use super::KeyEventHandler; mod blocklist; mod downloads; diff --git a/src/handlers/sonarr_handlers/root_folders/mod.rs b/src/handlers/sonarr_handlers/root_folders/mod.rs index 9d6a3ee..964060c 100644 --- a/src/handlers/sonarr_handlers/root_folders/mod.rs +++ b/src/handlers/sonarr_handlers/root_folders/mod.rs @@ -3,9 +3,9 @@ use crate::event::Key; use crate::handlers::sonarr_handlers::handle_change_tab_left_right_keys; use crate::handlers::table_handler::{TableHandlingConfig, handle_table}; use crate::handlers::{KeyEventHandler, handle_clear_errors, handle_prompt_toggle}; -use crate::models::{HorizontallyScrollableText, Route}; use crate::models::servarr_data::sonarr::sonarr_data::{ActiveSonarrBlock, ROOT_FOLDERS_BLOCKS}; use crate::models::servarr_models::AddRootFolderBody; +use crate::models::{HorizontallyScrollableText, Route}; use crate::network::sonarr_network::SonarrEvent; use crate::{handle_text_box_keys, handle_text_box_left_right_keys, matches_key}; diff --git a/src/handlers/sonarr_handlers/system/mod.rs b/src/handlers/sonarr_handlers/system/mod.rs index 34c0fff..0734350 100644 --- a/src/handlers/sonarr_handlers/system/mod.rs +++ b/src/handlers/sonarr_handlers/system/mod.rs @@ -4,8 +4,8 @@ use crate::handlers::sonarr_handlers::handle_change_tab_left_right_keys; use crate::handlers::sonarr_handlers::system::system_details_handler::SystemDetailsHandler; use crate::handlers::{KeyEventHandler, handle_clear_errors}; use crate::matches_key; -use crate::models::{Route, Scrollable}; use crate::models::servarr_data::sonarr::sonarr_data::ActiveSonarrBlock; +use crate::models::{Route, Scrollable}; mod system_details_handler; diff --git a/src/handlers/sonarr_handlers/system/system_details_handler.rs b/src/handlers/sonarr_handlers/system/system_details_handler.rs index 3b79e0d..63495fd 100644 --- a/src/handlers/sonarr_handlers/system/system_details_handler.rs +++ b/src/handlers/sonarr_handlers/system/system_details_handler.rs @@ -2,10 +2,10 @@ use crate::app::App; use crate::event::Key; use crate::handlers::{KeyEventHandler, handle_prompt_toggle}; use crate::matches_key; -use crate::models::{Route, Scrollable}; use crate::models::servarr_data::sonarr::sonarr_data::{ActiveSonarrBlock, SYSTEM_DETAILS_BLOCKS}; use crate::models::sonarr_models::SonarrTaskName; use crate::models::stateful_list::StatefulList; +use crate::models::{Route, Scrollable}; use crate::network::sonarr_network::SonarrEvent; #[cfg(test)] diff --git a/src/handlers/table_handler_tests.rs b/src/handlers/table_handler_tests.rs index fb0585d..0e3ff10 100644 --- a/src/handlers/table_handler_tests.rs +++ b/src/handlers/table_handler_tests.rs @@ -9,12 +9,12 @@ mod tests { use crate::handlers::KeyEventHandler; use crate::handlers::table_handler::TableHandlingConfig; use crate::handlers::table_handler::handle_table; + use crate::models::Route; use crate::models::radarr_models::Movie; use crate::models::servarr_data::radarr::radarr_data::ActiveRadarrBlock; use crate::models::servarr_models::Language; use crate::models::stateful_table::SortOption; use rstest::rstest; - use crate::models::Route; struct TableHandlerUnit<'a, 'b> { key: Key, diff --git a/src/models/servarr_data/lidarr/lidarr_data.rs b/src/models/servarr_data/lidarr/lidarr_data.rs index bc54f32..240f68d 100644 --- a/src/models/servarr_data/lidarr/lidarr_data.rs +++ b/src/models/servarr_data/lidarr/lidarr_data.rs @@ -1,5 +1,6 @@ use serde_json::Number; +use super::modals::EditArtistModal; use crate::app::lidarr::lidarr_context_clues::ARTISTS_CONTEXT_CLUES; use crate::models::{ BlockSelectionState, Route, TabRoute, TabState, @@ -10,16 +11,17 @@ use crate::models::{ use crate::network::lidarr_network::LidarrEvent; use bimap::BiMap; use chrono::{DateTime, Utc}; -use strum::{EnumIter}; -use super::modals::EditArtistModal; +use strum::EnumIter; #[cfg(test)] use { - strum::{Display, EnumString, IntoEnumIterator}, crate::models::lidarr_models::NewItemMonitorType, crate::models::stateful_table::SortOption, crate::network::lidarr_network::lidarr_network_test_utils::test_utils::quality_profile_map, + crate::network::lidarr_network::lidarr_network_test_utils::test_utils::{ + download_record, metadata_profile, metadata_profile_map, quality_profile, root_folder, tags_map, + }, crate::network::servarr_test_utils::diskspace, - crate::network::lidarr_network::lidarr_network_test_utils::test_utils::{download_record, metadata_profile, metadata_profile_map, quality_profile, root_folder, tags_map}, + strum::{Display, EnumString, IntoEnumIterator}, }; #[cfg(test)] @@ -114,9 +116,15 @@ impl LidarrData<'_> { tags: "alex".into(), ..EditArtistModal::default() }; - edit_artist_modal.monitor_list.set_items(NewItemMonitorType::iter().collect()); - edit_artist_modal.quality_profile_list.set_items(vec![quality_profile().name]); - edit_artist_modal.metadata_profile_list.set_items(vec![metadata_profile().name]); + edit_artist_modal + .monitor_list + .set_items(NewItemMonitorType::iter().collect()); + edit_artist_modal + .quality_profile_list + .set_items(vec![quality_profile().name]); + edit_artist_modal + .metadata_profile_list + .set_items(vec![metadata_profile().name]); let mut lidarr_data = LidarrData { delete_artist_files: true, @@ -134,12 +142,8 @@ impl LidarrData<'_> { }]); lidarr_data.artists.search = Some("artist search".into()); lidarr_data.artists.filter = Some("artist filter".into()); - lidarr_data - .downloads - .set_items(vec![download_record()]); - lidarr_data - .root_folders - .set_items(vec![root_folder()]); + lidarr_data.downloads.set_items(vec![download_record()]); + lidarr_data.root_folders.set_items(vec![root_folder()]); lidarr_data.version = "1.0.0".to_owned(); lidarr_data diff --git a/src/models/servarr_data/lidarr/lidarr_data_tests.rs b/src/models/servarr_data/lidarr/lidarr_data_tests.rs index 182bf48..ceb3417 100644 --- a/src/models/servarr_data/lidarr/lidarr_data_tests.rs +++ b/src/models/servarr_data/lidarr/lidarr_data_tests.rs @@ -1,12 +1,15 @@ #[cfg(test)] mod tests { - use bimap::BiMap; use crate::app::lidarr::lidarr_context_clues::ARTISTS_CONTEXT_CLUES; - use crate::models::servarr_data::lidarr::lidarr_data::{DELETE_ARTIST_BLOCKS, DELETE_ARTIST_SELECTION_BLOCKS, EDIT_ARTIST_BLOCKS, EDIT_ARTIST_SELECTION_BLOCKS}; + use crate::models::servarr_data::lidarr::lidarr_data::{ + DELETE_ARTIST_BLOCKS, DELETE_ARTIST_SELECTION_BLOCKS, EDIT_ARTIST_BLOCKS, + EDIT_ARTIST_SELECTION_BLOCKS, + }; use crate::models::{ BlockSelectionState, Route, servarr_data::lidarr::lidarr_data::{ActiveLidarrBlock, LIBRARY_BLOCKS, LidarrData}, }; + use bimap::BiMap; use chrono::{DateTime, Utc}; use pretty_assertions::{assert_eq, assert_str_eq}; use serde_json::Number; @@ -52,7 +55,10 @@ mod tests { ..LidarrData::default() }; - assert_str_eq!(lidarr_data.tag_ids_to_display(&[Number::from(1), Number::from(2)]), "test 1, test 2"); + assert_str_eq!( + lidarr_data.tag_ids_to_display(&[Number::from(1), Number::from(2)]), + "test 1, test 2" + ); } #[test] @@ -65,9 +71,16 @@ mod tests { quality_profile_map, ..LidarrData::default() }; - let expected_quality_profile_vec = vec!["test 1".to_owned(), "test 2".to_owned(), "test 3".to_owned()]; + let expected_quality_profile_vec = vec![ + "test 1".to_owned(), + "test 2".to_owned(), + "test 3".to_owned(), + ]; - assert_iter_eq!(lidarr_data.sorted_quality_profile_names(), expected_quality_profile_vec); + assert_iter_eq!( + lidarr_data.sorted_quality_profile_names(), + expected_quality_profile_vec + ); } #[test] @@ -80,9 +93,16 @@ mod tests { metadata_profile_map, ..LidarrData::default() }; - let expected_metadata_profile_vec = vec!["test 1".to_owned(), "test 2".to_owned(), "test 3".to_owned()]; + let expected_metadata_profile_vec = vec![ + "test 1".to_owned(), + "test 2".to_owned(), + "test 3".to_owned(), + ]; - assert_iter_eq!(lidarr_data.sorted_metadata_profile_names(), expected_metadata_profile_vec); + assert_iter_eq!( + lidarr_data.sorted_metadata_profile_names(), + expected_metadata_profile_vec + ); } #[test] @@ -176,13 +196,34 @@ mod tests { fn test_edit_artist_selection_blocks_ordering() { let mut edit_artist_block_iter = EDIT_ARTIST_SELECTION_BLOCKS.iter(); - assert_eq!(edit_artist_block_iter.next().unwrap(), &[ActiveLidarrBlock::EditArtistToggleMonitored]); - assert_eq!(edit_artist_block_iter.next().unwrap(), &[ActiveLidarrBlock::EditArtistSelectMonitorNewItems]); - assert_eq!(edit_artist_block_iter.next().unwrap(), &[ActiveLidarrBlock::EditArtistSelectQualityProfile]); - assert_eq!(edit_artist_block_iter.next().unwrap(), &[ActiveLidarrBlock::EditArtistSelectMetadataProfile]); - assert_eq!(edit_artist_block_iter.next().unwrap(), &[ActiveLidarrBlock::EditArtistPathInput]); - assert_eq!(edit_artist_block_iter.next().unwrap(), &[ActiveLidarrBlock::EditArtistTagsInput]); - assert_eq!(edit_artist_block_iter.next().unwrap(), &[ActiveLidarrBlock::EditArtistConfirmPrompt]); + assert_eq!( + edit_artist_block_iter.next().unwrap(), + &[ActiveLidarrBlock::EditArtistToggleMonitored] + ); + assert_eq!( + edit_artist_block_iter.next().unwrap(), + &[ActiveLidarrBlock::EditArtistSelectMonitorNewItems] + ); + assert_eq!( + edit_artist_block_iter.next().unwrap(), + &[ActiveLidarrBlock::EditArtistSelectQualityProfile] + ); + assert_eq!( + edit_artist_block_iter.next().unwrap(), + &[ActiveLidarrBlock::EditArtistSelectMetadataProfile] + ); + assert_eq!( + edit_artist_block_iter.next().unwrap(), + &[ActiveLidarrBlock::EditArtistPathInput] + ); + assert_eq!( + edit_artist_block_iter.next().unwrap(), + &[ActiveLidarrBlock::EditArtistTagsInput] + ); + assert_eq!( + edit_artist_block_iter.next().unwrap(), + &[ActiveLidarrBlock::EditArtistConfirmPrompt] + ); assert_none!(edit_artist_block_iter.next()); } } diff --git a/src/models/servarr_data/lidarr/modals_tests.rs b/src/models/servarr_data/lidarr/modals_tests.rs index 0889091..ae8b512 100644 --- a/src/models/servarr_data/lidarr/modals_tests.rs +++ b/src/models/servarr_data/lidarr/modals_tests.rs @@ -10,10 +10,16 @@ mod tests { #[test] fn test_edit_artist_modal_from_lidarr_data() { let mut lidarr_data = LidarrData { - quality_profile_map: BiMap::from_iter([(1i64, "HD - 1080p".to_owned()), (2i64, "Any".to_owned())]), - metadata_profile_map: BiMap::from_iter([(1i64, "Standard".to_owned()), (2i64, "None".to_owned())]), + quality_profile_map: BiMap::from_iter([ + (1i64, "HD - 1080p".to_owned()), + (2i64, "Any".to_owned()), + ]), + metadata_profile_map: BiMap::from_iter([ + (1i64, "Standard".to_owned()), + (2i64, "None".to_owned()), + ]), tags_map: BiMap::from_iter([(1i64, "usenet".to_owned())]), - ..LidarrData::default() + ..LidarrData::default() }; let artist = Artist { id: 1, diff --git a/src/models/servarr_data/radarr/radarr_data_tests.rs b/src/models/servarr_data/radarr/radarr_data_tests.rs index 15c8b6d..3ddde2e 100644 --- a/src/models/servarr_data/radarr/radarr_data_tests.rs +++ b/src/models/servarr_data/radarr/radarr_data_tests.rs @@ -1,10 +1,6 @@ #[cfg(test)] mod tests { mod radarr_data_tests { - use bimap::BiMap; - use chrono::{DateTime, Utc}; - use pretty_assertions::{assert_eq, assert_str_eq}; - use serde_json::Number; use crate::app::context_clues::{ BLOCKLIST_CONTEXT_CLUES, DOWNLOADS_CONTEXT_CLUES, INDEXERS_CONTEXT_CLUES, ROOT_FOLDERS_CONTEXT_CLUES, SYSTEM_CONTEXT_CLUES, @@ -13,6 +9,10 @@ mod tests { COLLECTIONS_CONTEXT_CLUES, LIBRARY_CONTEXT_CLUES, MANUAL_MOVIE_SEARCH_CONTEXT_CLUES, MOVIE_DETAILS_CONTEXT_CLUES, }; + use bimap::BiMap; + use chrono::{DateTime, Utc}; + use pretty_assertions::{assert_eq, assert_str_eq}; + use serde_json::Number; use crate::models::Route; use crate::models::servarr_data::radarr::radarr_data::radarr_test_utils::utils; @@ -73,7 +73,10 @@ mod tests { ..RadarrData::default() }; - assert_str_eq!(radarr_data.tag_ids_to_display(&[Number::from(1), Number::from(2)]), "test 1, test 2"); + assert_str_eq!( + radarr_data.tag_ids_to_display(&[Number::from(1), Number::from(2)]), + "test 1, test 2" + ); } #[test] @@ -86,9 +89,16 @@ mod tests { quality_profile_map, ..RadarrData::default() }; - let expected_quality_profile_vec = vec!["test 1".to_owned(), "test 2".to_owned(), "test 3".to_owned()]; + let expected_quality_profile_vec = vec![ + "test 1".to_owned(), + "test 2".to_owned(), + "test 3".to_owned(), + ]; - assert_iter_eq!(radarr_data.sorted_quality_profile_names(), expected_quality_profile_vec); + assert_iter_eq!( + radarr_data.sorted_quality_profile_names(), + expected_quality_profile_vec + ); } #[test] diff --git a/src/models/servarr_data/sonarr/sonarr_data_tests.rs b/src/models/servarr_data/sonarr/sonarr_data_tests.rs index 641a5f6..c166cfb 100644 --- a/src/models/servarr_data/sonarr/sonarr_data_tests.rs +++ b/src/models/servarr_data/sonarr/sonarr_data_tests.rs @@ -1,10 +1,6 @@ #[cfg(test)] mod tests { mod sonarr_data_tests { - use bimap::BiMap; - use chrono::{DateTime, Utc}; - use pretty_assertions::{assert_eq, assert_str_eq}; - use serde_json::Number; use crate::app::sonarr::sonarr_context_clues::SERIES_HISTORY_CONTEXT_CLUES; use crate::models::sonarr_models::{Season, SonarrHistoryItem}; use crate::models::stateful_table::StatefulTable; @@ -23,6 +19,10 @@ mod tests { servarr_data::sonarr::sonarr_data::{ActiveSonarrBlock, SonarrData}, }, }; + use bimap::BiMap; + use chrono::{DateTime, Utc}; + use pretty_assertions::{assert_eq, assert_str_eq}; + use serde_json::Number; #[test] fn test_from_active_sonarr_block_to_route() { @@ -89,7 +89,10 @@ mod tests { ..SonarrData::default() }; - assert_str_eq!(sonarr_data.tag_ids_to_display(&[Number::from(1), Number::from(2)]), "test 1, test 2"); + assert_str_eq!( + sonarr_data.tag_ids_to_display(&[Number::from(1), Number::from(2)]), + "test 1, test 2" + ); } #[test] @@ -102,9 +105,16 @@ mod tests { quality_profile_map, ..SonarrData::default() }; - let expected_quality_profile_vec = vec!["test 1".to_owned(), "test 2".to_owned(), "test 3".to_owned()]; + let expected_quality_profile_vec = vec![ + "test 1".to_owned(), + "test 2".to_owned(), + "test 3".to_owned(), + ]; - assert_iter_eq!(sonarr_data.sorted_quality_profile_names(), expected_quality_profile_vec); + assert_iter_eq!( + sonarr_data.sorted_quality_profile_names(), + expected_quality_profile_vec + ); } #[test] @@ -117,9 +127,16 @@ mod tests { language_profiles_map, ..SonarrData::default() }; - let expected_language_profiles_vec = vec!["test 1".to_owned(), "test 2".to_owned(), "test 3".to_owned()]; + let expected_language_profiles_vec = vec![ + "test 1".to_owned(), + "test 2".to_owned(), + "test 3".to_owned(), + ]; - assert_iter_eq!(sonarr_data.sorted_language_profile_names(), expected_language_profiles_vec); + assert_iter_eq!( + sonarr_data.sorted_language_profile_names(), + expected_language_profiles_vec + ); } #[test] diff --git a/src/network/lidarr_network/library/lidarr_library_network_tests.rs b/src/network/lidarr_network/library/lidarr_library_network_tests.rs index 6cb6c3e..ebdefaf 100644 --- a/src/network/lidarr_network/library/lidarr_library_network_tests.rs +++ b/src/network/lidarr_network/library/lidarr_library_network_tests.rs @@ -1,11 +1,16 @@ #[cfg(test)] mod tests { - use crate::models::lidarr_models::{Artist, DeleteArtistParams, LidarrSerdeable}; + use crate::models::lidarr_models::{ + Artist, DeleteArtistParams, EditArtistParams, LidarrSerdeable, NewItemMonitorType, + }; + use crate::network::NetworkResource; use crate::network::lidarr_network::LidarrEvent; use crate::network::network_tests::test_utils::{MockServarrApi, test_network}; + use bimap::BiMap; use mockito::Matcher; use pretty_assertions::assert_eq; - use serde_json::json; + use serde_json::{Value, json}; + use crate::network::lidarr_network::lidarr_network_test_utils::test_utils::ARTIST_JSON; #[tokio::test] async fn test_handle_list_artists_event() { @@ -70,36 +75,9 @@ mod tests { #[tokio::test] async fn test_handle_get_artist_details_event() { - let artist_json = json!({ - "id": 1, - "artistName": "Test Artist", - "foreignArtistId": "test-foreign-id", - "status": "continuing", - "overview": "some interesting description of the artist", - "artistType": "Person", - "disambiguation": "American pianist", - "path": "/music/test-artist", - "members": [{"name": "alex", "instrument": "piano"}], - "qualityProfileId": 1, - "metadataProfileId": 1, - "monitored": true, - "monitorNewItems": "all", - "genres": ["soundtrack"], - "tags": [1], - "added": "2023-01-01T00:00:00Z", - "ratings": { "votes": 15, "value": 8.4 }, - "statistics": { - "albumCount": 1, - "trackFileCount": 15, - "trackCount": 15, - "totalTrackCount": 15, - "sizeOnDisk": 12345, - "percentOfTracks": 99.9 - } - }); - let response: Artist = serde_json::from_value(artist_json.clone()).unwrap(); + let expected_artist: Artist = serde_json::from_str(ARTIST_JSON).unwrap(); let (mock, app, _server) = MockServarrApi::get() - .returns(artist_json) + .returns(serde_json::from_str(ARTIST_JSON).unwrap()) .path("/1") .build_for(LidarrEvent::GetArtistDetails(1)) .await; @@ -116,7 +94,7 @@ mod tests { panic!("Expected Artist"); }; - assert_eq!(artist, response); + assert_eq!(artist, expected_artist); } #[tokio::test] @@ -184,4 +162,198 @@ mod tests { mock.assert_async().await; } + + #[tokio::test] + async fn test_handle_edit_artist_event() { + let mut expected_body: Value = serde_json::from_str(ARTIST_JSON).unwrap(); + *expected_body.get_mut("monitored").unwrap() = json!(false); + *expected_body.get_mut("monitorNewItems").unwrap() = json!("none"); + *expected_body.get_mut("qualityProfileId").unwrap() = json!(1111); + *expected_body.get_mut("metadataProfileId").unwrap() = json!(2222); + *expected_body.get_mut("path").unwrap() = json!("/nfs/Test Path"); + *expected_body.get_mut("tags").unwrap() = json!([1, 2]); + let edit_artist_params = EditArtistParams { + artist_id: 1, + monitored: Some(false), + monitor_new_items: Some(NewItemMonitorType::None), + quality_profile_id: Some(1111), + metadata_profile_id: Some(2222), + root_folder_path: Some("/nfs/Test Path".to_owned()), + tag_input_string: Some("usenet, testing".to_owned()), + ..EditArtistParams::default() + }; + + let (async_details_server, app, mut server) = MockServarrApi::get() + .returns(serde_json::from_str(ARTIST_JSON).unwrap()) + .path("/1") + .build_for(LidarrEvent::GetArtistDetails(1)) + .await; + let async_edit_server = server + .mock( + "PUT", + format!( + "/api/v1{}/1", + LidarrEvent::EditArtist(edit_artist_params.clone()).resource() + ) + .as_str(), + ) + .with_status(202) + .match_header("X-Api-Key", "test1234") + .match_body(Matcher::Json(expected_body)) + .create_async() + .await; + app.lock().await.data.lidarr_data.tags_map = + BiMap::from_iter([(1, "usenet".to_owned()), (2, "testing".to_owned())]); + app.lock().await.server_tabs.set_index(2); + let mut network = test_network(&app); + + assert!( + network + .handle_lidarr_event(LidarrEvent::EditArtist(edit_artist_params)) + .await + .is_ok() + ); + + async_details_server.assert_async().await; + async_edit_server.assert_async().await; + } + + #[tokio::test] + async fn test_handle_edit_artist_event_does_not_overwrite_tag_ids_vec_when_tag_input_string_is_none() + { + let mut expected_body: Value = serde_json::from_str(ARTIST_JSON).unwrap(); + *expected_body.get_mut("monitored").unwrap() = json!(false); + *expected_body.get_mut("monitorNewItems").unwrap() = json!("none"); + *expected_body.get_mut("qualityProfileId").unwrap() = json!(1111); + *expected_body.get_mut("metadataProfileId").unwrap() = json!(2222); + *expected_body.get_mut("path").unwrap() = json!("/nfs/Test Path"); + *expected_body.get_mut("tags").unwrap() = json!([1, 2]); + let edit_artist_params = EditArtistParams { + artist_id: 1, + monitored: Some(false), + monitor_new_items: Some(NewItemMonitorType::None), + quality_profile_id: Some(1111), + metadata_profile_id: Some(2222), + root_folder_path: Some("/nfs/Test Path".to_owned()), + tags: Some(vec![1, 2]), + ..EditArtistParams::default() + }; + + let (async_details_server, app, mut server) = MockServarrApi::get() + .returns(serde_json::from_str(ARTIST_JSON).unwrap()) + .path("/1") + .build_for(LidarrEvent::GetArtistDetails(1)) + .await; + let async_edit_server = server + .mock( + "PUT", + format!( + "/api/v1{}/1", + LidarrEvent::EditArtist(edit_artist_params.clone()).resource() + ) + .as_str(), + ) + .with_status(202) + .match_header("X-Api-Key", "test1234") + .match_body(Matcher::Json(expected_body)) + .create_async() + .await; + app.lock().await.data.lidarr_data.tags_map = + BiMap::from_iter([(1, "usenet".to_owned()), (2, "testing".to_owned())]); + app.lock().await.server_tabs.set_index(2); + let mut network = test_network(&app); + + assert!( + network + .handle_lidarr_event(LidarrEvent::EditArtist(edit_artist_params)) + .await + .is_ok() + ); + + async_details_server.assert_async().await; + async_edit_server.assert_async().await; + } + + #[tokio::test] + async fn test_handle_edit_artist_event_defaults_to_previous_values() { + let edit_artist_params = EditArtistParams { + artist_id: 1, + ..EditArtistParams::default() + }; + let expected_body: Value = serde_json::from_str(ARTIST_JSON).unwrap(); + let (async_details_server, app, mut server) = MockServarrApi::get() + .returns(serde_json::from_str(ARTIST_JSON).unwrap()) + .path("/1") + .build_for(LidarrEvent::GetArtistDetails(1)) + .await; + let async_edit_server = server + .mock( + "PUT", + format!( + "/api/v1{}/1", + LidarrEvent::EditArtist(edit_artist_params.clone()).resource() + ) + .as_str(), + ) + .with_status(202) + .match_header("X-Api-Key", "test1234") + .match_body(Matcher::Json(expected_body)) + .create_async() + .await; + app.lock().await.server_tabs.set_index(2); + let mut network = test_network(&app); + + assert!( + network + .handle_lidarr_event(LidarrEvent::EditArtist(edit_artist_params)) + .await + .is_ok() + ); + + async_details_server.assert_async().await; + async_edit_server.assert_async().await; + } + + #[tokio::test] + async fn test_handle_edit_artist_event_returns_empty_tags_vec_when_clear_tags_is_true() { + let mut expected_body: Value = serde_json::from_str(ARTIST_JSON).unwrap(); + *expected_body.get_mut("tags").unwrap() = json!([]); + + let (async_details_server, app, mut server) = MockServarrApi::get() + .returns(serde_json::from_str(ARTIST_JSON).unwrap()) + .path("/1") + .build_for(LidarrEvent::GetArtistDetails(1)) + .await; + let edit_artist_params = EditArtistParams { + artist_id: 1, + clear_tags: true, + ..EditArtistParams::default() + }; + let async_edit_server = server + .mock( + "PUT", + format!( + "/api/v1{}/1", + LidarrEvent::EditArtist(edit_artist_params.clone()).resource() + ) + .as_str(), + ) + .with_status(202) + .match_header("X-Api-Key", "test1234") + .match_body(Matcher::Json(expected_body)) + .create_async() + .await; + app.lock().await.server_tabs.set_index(2); + let mut network = test_network(&app); + + assert!( + network + .handle_lidarr_event(LidarrEvent::EditArtist(edit_artist_params)) + .await + .is_ok() + ); + + async_details_server.assert_async().await; + async_edit_server.assert_async().await; + } } diff --git a/src/network/lidarr_network/lidarr_network_test_utils.rs b/src/network/lidarr_network/lidarr_network_test_utils.rs index 8677e6c..4e6f9dc 100644 --- a/src/network/lidarr_network/lidarr_network_test_utils.rs +++ b/src/network/lidarr_network/lidarr_network_test_utils.rs @@ -1,132 +1,177 @@ #[cfg(test)] #[allow(dead_code)] // TODO: maybe remove? pub mod test_utils { - use crate::models::lidarr_models::{Artist, ArtistStatistics, ArtistStatus, DownloadRecord, DownloadStatus, DownloadsResponse, Member, MetadataProfile, NewItemMonitorType, Ratings, SystemStatus}; - use crate::models::servarr_models::{QualityProfile, RootFolder, Tag}; - use crate::models::HorizontallyScrollableText; - use bimap::BiMap; - use chrono::DateTime; - use serde_json::Number; + use crate::models::HorizontallyScrollableText; + use crate::models::lidarr_models::{ + Artist, ArtistStatistics, ArtistStatus, DownloadRecord, DownloadStatus, DownloadsResponse, + EditArtistParams, Member, MetadataProfile, NewItemMonitorType, Ratings, SystemStatus, + }; + use crate::models::servarr_models::{QualityProfile, RootFolder, Tag}; + use bimap::BiMap; + use chrono::DateTime; + use serde_json::Number; - pub fn member() -> Member { - Member { - name: Some("alex".to_owned()), - instrument: Some("piano".to_owned()) - } - } + pub const ARTIST_JSON: &str = r#"{ + "id": 1, + "artistName": "Test Artist", + "foreignArtistId": "test-foreign-id", + "status": "continuing", + "overview": "some interesting description of the artist", + "artistType": "Person", + "disambiguation": "American pianist", + "path": "/music/test-artist", + "members": [{"name": "alex", "instrument": "piano"}], + "qualityProfileId": 1, + "metadataProfileId": 1, + "monitored": true, + "monitorNewItems": "all", + "genres": ["soundtrack"], + "tags": [1], + "added": "2023-01-01T00:00:00Z", + "ratings": { "votes": 15, "value": 8.4 }, + "statistics": { + "albumCount": 1, + "trackFileCount": 15, + "trackCount": 15, + "totalTrackCount": 15, + "sizeOnDisk": 12345, + "percentOfTracks": 99.9 + } + }"#; - pub fn ratings() -> Ratings { - Ratings { - votes: 15, - value: 8.4 - } - } + pub fn member() -> Member { + Member { + name: Some("alex".to_owned()), + instrument: Some("piano".to_owned()), + } + } - pub fn artist_statistics() -> ArtistStatistics { - ArtistStatistics { - album_count: 1, - track_file_count: 15, - track_count: 15, - total_track_count: 15, - size_on_disk: 12345, - percent_of_tracks: 99.9 - } - } + pub fn ratings() -> Ratings { + Ratings { + votes: 15, + value: 8.4, + } + } - pub fn artist() -> Artist { - Artist { - id: 1, - artist_name: "Alex".into(), - foreign_artist_id: "test-foreign-id".to_owned(), - status: ArtistStatus::Continuing, - overview: Some("some interesting description of the artist".to_owned()), - artist_type: Some("Person".to_owned()), - disambiguation: Some("American pianist".to_owned()), - members: Some(vec![member()]), - path: "/nfs/music/test-artist".to_owned(), - quality_profile_id: quality_profile().id, - metadata_profile_id: metadata_profile().id, - monitored: true, - monitor_new_items: NewItemMonitorType::All, - genres: vec!["soundtrack".to_owned()], - tags: vec![Number::from(tag().id)], - added: DateTime::from(DateTime::parse_from_rfc3339("2023-01-01T00:00:00Z").unwrap()), - ratings: Some(ratings()), - statistics: Some(artist_statistics()) - } - } + pub fn artist_statistics() -> ArtistStatistics { + ArtistStatistics { + album_count: 1, + track_file_count: 15, + track_count: 15, + total_track_count: 15, + size_on_disk: 12345, + percent_of_tracks: 99.9, + } + } - pub fn quality_profile() -> QualityProfile { - QualityProfile { - id: 1, - name: "Lossless".to_owned() - } - } + pub fn artist() -> Artist { + Artist { + id: 1, + artist_name: "Alex".into(), + foreign_artist_id: "test-foreign-id".to_owned(), + status: ArtistStatus::Continuing, + overview: Some("some interesting description of the artist".to_owned()), + artist_type: Some("Person".to_owned()), + disambiguation: Some("American pianist".to_owned()), + members: Some(vec![member()]), + path: "/nfs/music/test-artist".to_owned(), + quality_profile_id: quality_profile().id, + metadata_profile_id: metadata_profile().id, + monitored: true, + monitor_new_items: NewItemMonitorType::All, + genres: vec!["soundtrack".to_owned()], + tags: vec![Number::from(tag().id)], + added: DateTime::from(DateTime::parse_from_rfc3339("2023-01-01T00:00:00Z").unwrap()), + ratings: Some(ratings()), + statistics: Some(artist_statistics()), + } + } - pub fn quality_profile_map() -> BiMap { - let quality_profile = quality_profile(); - BiMap::from_iter(vec![(quality_profile.id, quality_profile.name)]) - } + pub fn quality_profile() -> QualityProfile { + QualityProfile { + id: 1, + name: "Lossless".to_owned(), + } + } - pub fn metadata_profile() -> MetadataProfile { - MetadataProfile { - id: 1, - name: "Standard".to_owned() - } - } + pub fn quality_profile_map() -> BiMap { + let quality_profile = quality_profile(); + BiMap::from_iter(vec![(quality_profile.id, quality_profile.name)]) + } - pub fn metadata_profile_map() -> BiMap { - let metadata_profile = metadata_profile(); - BiMap::from_iter(vec![(metadata_profile.id, metadata_profile.name)]) - } + pub fn metadata_profile() -> MetadataProfile { + MetadataProfile { + id: 1, + name: "Standard".to_owned(), + } + } - pub fn tag() -> Tag { - Tag { - id: 1, - label: "alex".to_owned() - } - } + pub fn metadata_profile_map() -> BiMap { + let metadata_profile = metadata_profile(); + BiMap::from_iter(vec![(metadata_profile.id, metadata_profile.name)]) + } - pub fn tags_map() -> BiMap { - let tag = tag(); - BiMap::from_iter(vec![(tag.id, tag.label)]) - } + pub fn tag() -> Tag { + Tag { + id: 1, + label: "alex".to_owned(), + } + } - pub fn download_record() -> DownloadRecord { - DownloadRecord { - title: "Test download title".to_owned(), - status: DownloadStatus::Downloading, - id: 1, - album_id: Some(Number::from(1i64)), - artist_id: Some(Number::from(1i64)), - size: 3543348019f64, - sizeleft: 1771674009f64, - output_path: Some(HorizontallyScrollableText::from("/nfs/music/alex/album")), - indexer: "kickass torrents".to_owned(), - download_client: Some("transmission".to_owned()) - } - } + pub fn tags_map() -> BiMap { + let tag = tag(); + BiMap::from_iter(vec![(tag.id, tag.label)]) + } - pub fn downloads_response() -> DownloadsResponse { - DownloadsResponse { - records: vec![download_record()] - } - } - - pub fn system_status() -> SystemStatus { - SystemStatus { - version: "1.0".to_owned(), - start_time: DateTime::from(DateTime::parse_from_rfc3339("2023-01-01T00:00:00Z").unwrap()), - } - } - - pub fn root_folder() -> RootFolder { - RootFolder { - id: 1, - path: "/nfs".to_owned(), - accessible: true, - free_space: 219902325555200, - unmapped_folders: None, - } - } -} \ No newline at end of file + pub fn download_record() -> DownloadRecord { + DownloadRecord { + title: "Test download title".to_owned(), + status: DownloadStatus::Downloading, + id: 1, + album_id: Some(Number::from(1i64)), + artist_id: Some(Number::from(1i64)), + size: 3543348019f64, + sizeleft: 1771674009f64, + output_path: Some(HorizontallyScrollableText::from("/nfs/music/alex/album")), + indexer: "kickass torrents".to_owned(), + download_client: Some("transmission".to_owned()), + } + } + + pub fn downloads_response() -> DownloadsResponse { + DownloadsResponse { + records: vec![download_record()], + } + } + + pub fn system_status() -> SystemStatus { + SystemStatus { + version: "1.0".to_owned(), + start_time: DateTime::from(DateTime::parse_from_rfc3339("2023-01-01T00:00:00Z").unwrap()), + } + } + + pub fn root_folder() -> RootFolder { + RootFolder { + id: 1, + path: "/nfs".to_owned(), + accessible: true, + free_space: 219902325555200, + unmapped_folders: None, + } + } + + pub fn edit_artist_params() -> EditArtistParams { + EditArtistParams { + artist_id: artist().id, + monitored: Some(true), + monitor_new_items: Some(NewItemMonitorType::All), + quality_profile_id: Some(quality_profile().id), + metadata_profile_id: Some(metadata_profile().id), + root_folder_path: Some("/nfs/music/test-artist".to_owned()), + tags: Some(vec![tag().id]), + tag_input_string: Some("alex".to_owned()), + clear_tags: false, + } + } +} diff --git a/src/network/lidarr_network/lidarr_network_tests.rs b/src/network/lidarr_network/lidarr_network_tests.rs index 837885f..0e487a3 100644 --- a/src/network/lidarr_network/lidarr_network_tests.rs +++ b/src/network/lidarr_network/lidarr_network_tests.rs @@ -1,12 +1,17 @@ #[cfg(test)] mod tests { + use std::sync::Arc; use crate::models::lidarr_models::{LidarrSerdeable, MetadataProfile}; use crate::models::servarr_models::{QualityProfile, Tag}; use crate::network::network_tests::test_utils::{MockServarrApi, test_network}; use crate::network::{NetworkEvent, NetworkResource, lidarr_network::LidarrEvent}; + use bimap::BiMap; use pretty_assertions::{assert_eq, assert_str_eq}; use rstest::rstest; use serde_json::json; + use tokio::sync::Mutex; + use crate::app::App; + use crate::models::servarr_data::lidarr::modals::EditArtistModal; #[rstest] fn test_resource_artist( @@ -170,4 +175,91 @@ mod tests { Some(&"usenet".to_owned()) ); } + + #[tokio::test] + async fn test_handle_add_lidarr_tag_event() { + let tag_json = json!({ + "id": 1, + "label": "usenet" + }); + let response: Tag = serde_json::from_value(tag_json.clone()).unwrap(); + let (mock, app, _server) = MockServarrApi::post() + .with_request_body(json!({ "label": "usenet" })) + .returns(tag_json) + .build_for(LidarrEvent::AddTag("usenet".to_owned())) + .await; + app.lock().await.server_tabs.set_index(2); + let mut network = test_network(&app); + + let result = network + .handle_lidarr_event(LidarrEvent::AddTag("usenet".to_owned())) + .await; + + mock.assert_async().await; + + let LidarrSerdeable::Tag(tag) = result.unwrap() else { + panic!("Expected Tag"); + }; + + assert_eq!(tag, response); + assert_eq!( + app.lock().await.data.lidarr_data.tags_map.get_by_left(&1), + Some(&"usenet".to_owned()) + ); + } + + #[tokio::test] + async fn test_extract_and_add_lidarr_tag_ids_vec() { + let app_arc = Arc::new(Mutex::new(App::test_default())); + let tags = " test,HI ,, usenet "; + { + let mut app = app_arc.lock().await; + app.data.lidarr_data.tags_map = BiMap::from_iter([ + (1, "usenet".to_owned()), + (2, "test".to_owned()), + (3, "hi".to_owned()), + ]); + } + app_arc.lock().await.server_tabs.set_index(2); + let mut network = test_network(&app_arc); + + assert_eq!( + network.extract_and_add_lidarr_tag_ids_vec(tags).await, + vec![2, 3, 1] + ); + } + + #[tokio::test] + async fn test_extract_and_add_lidarr_tag_ids_vec_add_missing_tags_first() { + let (mock, app, _server) = MockServarrApi::post() + .with_request_body(json!({ "label": "TESTING" })) + .returns(json!({ "id": 3, "label": "testing" })) + .build_for(LidarrEvent::GetTags) + .await; + let tags = "usenet, test, TESTING"; + { + let mut app_guard = app.lock().await; + app_guard.data.lidarr_data.edit_artist_modal = Some(EditArtistModal { + tags: tags.into(), + ..EditArtistModal::default() + }); + app_guard.data.lidarr_data.tags_map = + BiMap::from_iter([(1, "usenet".to_owned()), (2, "test".to_owned())]); + } + app.lock().await.server_tabs.set_index(2); + let mut network = test_network(&app); + + let tag_ids_vec = network.extract_and_add_lidarr_tag_ids_vec(tags).await; + + mock.assert_async().await; + assert_eq!(tag_ids_vec, vec![1, 2, 3]); + assert_eq!( + app.lock().await.data.lidarr_data.tags_map, + BiMap::from_iter([ + (1, "usenet".to_owned()), + (2, "test".to_owned()), + (3, "testing".to_owned()) + ]) + ); + } } diff --git a/src/ui/lidarr_ui/library/edit_artist_ui.rs b/src/ui/lidarr_ui/library/edit_artist_ui.rs index 7137904..9a45554 100644 --- a/src/ui/lidarr_ui/library/edit_artist_ui.rs +++ b/src/ui/lidarr_ui/library/edit_artist_ui.rs @@ -12,7 +12,7 @@ use crate::models::servarr_data::lidarr::modals::EditArtistModal; use crate::render_selectable_input_box; use crate::ui::styles::ManagarrStyle; -use crate::ui::utils::{title_block_centered}; +use crate::ui::utils::title_block_centered; use crate::ui::widgets::button::Button; use crate::ui::widgets::checkbox::Checkbox; use crate::ui::widgets::input_box::InputBox; @@ -91,7 +91,7 @@ fn draw_edit_artist_confirmation_prompt(f: &mut Frame<'_>, app: &mut App<'_>, ar let selected_metadata_profile = metadata_profile_list.current_selection(); let [ - _, + _, monitored_area, monitor_new_items_area, quality_profile_area, diff --git a/src/ui/lidarr_ui/library/edit_artist_ui_tests.rs b/src/ui/lidarr_ui/library/edit_artist_ui_tests.rs index 2980d1d..f6a12cc 100644 --- a/src/ui/lidarr_ui/library/edit_artist_ui_tests.rs +++ b/src/ui/lidarr_ui/library/edit_artist_ui_tests.rs @@ -1,22 +1,49 @@ #[cfg(test)] mod tests { - use pretty_assertions::assert_eq; use strum::IntoEnumIterator; - use crate::models::Route; - use crate::models::servarr_data::lidarr::lidarr_data::{ActiveLidarrBlock, EDIT_ARTIST_BLOCKS}; + use crate::app::App; + use crate::models::BlockSelectionState; + use crate::models::servarr_data::lidarr::lidarr_data::{ + ActiveLidarrBlock, EDIT_ARTIST_BLOCKS, EDIT_ARTIST_SELECTION_BLOCKS, + }; use crate::ui::DrawUi; use crate::ui::lidarr_ui::library::edit_artist_ui::EditArtistUi; + use crate::ui::ui_test_utils::test_utils::render_to_string_with_app; #[test] fn test_edit_artist_ui_accepts() { - let mut edit_artist_ui_blocks = Vec::new(); - for block in ActiveLidarrBlock::iter() { - if EditArtistUi::accepts(Route::Lidarr(block, None)) { - edit_artist_ui_blocks.push(block); + ActiveLidarrBlock::iter().for_each(|active_lidarr_block| { + if EDIT_ARTIST_BLOCKS.contains(&active_lidarr_block) { + assert!(EditArtistUi::accepts(active_lidarr_block.into())); + } else { + assert!(!EditArtistUi::accepts(active_lidarr_block.into())); } - } + }); + } - assert_eq!(edit_artist_ui_blocks, EDIT_ARTIST_BLOCKS.to_vec()); + mod snapshot_tests { + use crate::ui::ui_test_utils::test_utils::TerminalSize; + use rstest::rstest; + + use super::*; + + #[rstest] + #[case(ActiveLidarrBlock::EditArtistPrompt)] + #[case(ActiveLidarrBlock::EditArtistConfirmPrompt)] + #[case(ActiveLidarrBlock::EditArtistSelectMetadataProfile)] + #[case(ActiveLidarrBlock::EditArtistSelectMonitorNewItems)] + #[case(ActiveLidarrBlock::EditArtistSelectQualityProfile)] + fn test_edit_artist_ui_renders(#[case] active_lidarr_block: ActiveLidarrBlock) { + let mut app = App::test_default_fully_populated(); + app.push_navigation_stack(active_lidarr_block.into()); + app.data.lidarr_data.selected_block = BlockSelectionState::new(EDIT_ARTIST_SELECTION_BLOCKS); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + EditArtistUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(format!("edit_artist_{active_lidarr_block}"), output); + } } } diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__delete_artist_ui__delete_artist_ui_tests__tests__snapshot_tests__delete_artist_ui_renders_delete_artist.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__delete_artist_ui__delete_artist_ui_tests__tests__snapshot_tests__delete_artist_ui_renders_delete_artist.snap index 6329eb8..7c770ca 100644 --- a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__delete_artist_ui__delete_artist_ui_tests__tests__snapshot_tests__delete_artist_ui_renders_delete_artist.snap +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__delete_artist_ui__delete_artist_ui_tests__tests__snapshot_tests__delete_artist_ui_renders_delete_artist.snap @@ -24,7 +24,7 @@ expression: output │ │ │ │ │ ╭───╮ │ - │ Delete Artist Files: │ │ │ + │ Delete Artist Files: │ ✔ │ │ │ ╰───╯ │ │ ╭───╮ │ │ Add List Exclusion: │ │ │ diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__edit_artist_ui__edit_artist_ui_tests__tests__snapshot_tests__edit_artist_EditArtistConfirmPrompt.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__edit_artist_ui__edit_artist_ui_tests__tests__snapshot_tests__edit_artist_EditArtistConfirmPrompt.snap new file mode 100644 index 0000000..0518e01 --- /dev/null +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__edit_artist_ui__edit_artist_ui_tests__tests__snapshot_tests__edit_artist_EditArtistConfirmPrompt.snap @@ -0,0 +1,48 @@ +--- +source: src/ui/lidarr_ui/library/edit_artist_ui_tests.rs +expression: output +--- + + + + + + + ╭─────────────────────────────────────────────── Edit - ───────────────────────────────────────────────╮ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ ╭───╮ │ + │ Monitored: │ ✔ │ │ + │ ╰───╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Monitor New Albums: │All Albums ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Quality Profile: │Lossless ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Metadata Profile: │Standard ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Path: │/nfs/music │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Tags: │alex │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │╭───────────────────────────────────────────────────╮╭──────────────────────────────────────────────────╮│ + ││ Save ││ Cancel ││ + │╰───────────────────────────────────────────────────╯╰──────────────────────────────────────────────────╯│ + ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__edit_artist_ui__edit_artist_ui_tests__tests__snapshot_tests__edit_artist_EditArtistPrompt.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__edit_artist_ui__edit_artist_ui_tests__tests__snapshot_tests__edit_artist_EditArtistPrompt.snap new file mode 100644 index 0000000..0518e01 --- /dev/null +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__edit_artist_ui__edit_artist_ui_tests__tests__snapshot_tests__edit_artist_EditArtistPrompt.snap @@ -0,0 +1,48 @@ +--- +source: src/ui/lidarr_ui/library/edit_artist_ui_tests.rs +expression: output +--- + + + + + + + ╭─────────────────────────────────────────────── Edit - ───────────────────────────────────────────────╮ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ ╭───╮ │ + │ Monitored: │ ✔ │ │ + │ ╰───╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Monitor New Albums: │All Albums ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Quality Profile: │Lossless ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Metadata Profile: │Standard ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Path: │/nfs/music │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Tags: │alex │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │╭───────────────────────────────────────────────────╮╭──────────────────────────────────────────────────╮│ + ││ Save ││ Cancel ││ + │╰───────────────────────────────────────────────────╯╰──────────────────────────────────────────────────╯│ + ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__edit_artist_ui__edit_artist_ui_tests__tests__snapshot_tests__edit_artist_EditArtistSelectMetadataProfile.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__edit_artist_ui__edit_artist_ui_tests__tests__snapshot_tests__edit_artist_EditArtistSelectMetadataProfile.snap new file mode 100644 index 0000000..e07f1c6 --- /dev/null +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__edit_artist_ui__edit_artist_ui_tests__tests__snapshot_tests__edit_artist_EditArtistSelectMetadataProfile.snap @@ -0,0 +1,48 @@ +--- +source: src/ui/lidarr_ui/library/edit_artist_ui_tests.rs +expression: output +--- + + + + + + + ╭─────────────────────────────────────────────── Edit - ───────────────────────────────────────────────╮ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ ╭───╮ │ + │ Monitored: │ ✔ │ │ + │ ╰───╯ │ + │ ╭───────────────────────────────╮──────────────────────────────╮ │ + │ Monitor│Standard │ ▼ │ │ + │ │ │──────────────────────────────╯ │ + │ │ │──────────────────────────────╮ │ + │ Qual│ │ ▼ │ │ + │ │ │──────────────────────────────╯ │ + │ │ │──────────────────────────────╮ │ + │ Metad│ │ ▼ │ │ + │ │ │──────────────────────────────╯ │ + │ │ │──────────────────────────────╮ │ + │ │ │ │ │ + │ │ │──────────────────────────────╯ │ + │ │ │──────────────────────────────╮ │ + │ │ │ │ │ + │ ╰───────────────────────────────╯──────────────────────────────╯ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │╭───────────────────────────────────────────────────╮╭──────────────────────────────────────────────────╮│ + ││ Save ││ Cancel ││ + │╰───────────────────────────────────────────────────╯╰──────────────────────────────────────────────────╯│ + ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__edit_artist_ui__edit_artist_ui_tests__tests__snapshot_tests__edit_artist_EditArtistSelectMonitorNewItems.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__edit_artist_ui__edit_artist_ui_tests__tests__snapshot_tests__edit_artist_EditArtistSelectMonitorNewItems.snap new file mode 100644 index 0000000..b8d5af3 --- /dev/null +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__edit_artist_ui__edit_artist_ui_tests__tests__snapshot_tests__edit_artist_EditArtistSelectMonitorNewItems.snap @@ -0,0 +1,48 @@ +--- +source: src/ui/lidarr_ui/library/edit_artist_ui_tests.rs +expression: output +--- + + + + + + + ╭─────────────────────────────────────────────── Edit - ───────────────────────────────────────────────╮ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ ╭───╮ │ + │ Monitored: │ ✔ │ │ + │ ╰───╯ │ + │ ╭───────────────────────────────╮──────────────────────────────╮ │ + │ Monitor│All Albums │ ▼ │ │ + │ │No New Albums │──────────────────────────────╯ │ + │ │New Albums │──────────────────────────────╮ │ + │ Qual│ │ ▼ │ │ + │ │ │──────────────────────────────╯ │ + │ │ │──────────────────────────────╮ │ + │ Metad│ │ ▼ │ │ + │ │ │──────────────────────────────╯ │ + │ │ │──────────────────────────────╮ │ + │ │ │ │ │ + │ │ │──────────────────────────────╯ │ + │ │ │──────────────────────────────╮ │ + │ │ │ │ │ + │ ╰───────────────────────────────╯──────────────────────────────╯ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │╭───────────────────────────────────────────────────╮╭──────────────────────────────────────────────────╮│ + ││ Save ││ Cancel ││ + │╰───────────────────────────────────────────────────╯╰──────────────────────────────────────────────────╯│ + ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__edit_artist_ui__edit_artist_ui_tests__tests__snapshot_tests__edit_artist_EditArtistSelectQualityProfile.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__edit_artist_ui__edit_artist_ui_tests__tests__snapshot_tests__edit_artist_EditArtistSelectQualityProfile.snap new file mode 100644 index 0000000..fdb719d --- /dev/null +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__edit_artist_ui__edit_artist_ui_tests__tests__snapshot_tests__edit_artist_EditArtistSelectQualityProfile.snap @@ -0,0 +1,48 @@ +--- +source: src/ui/lidarr_ui/library/edit_artist_ui_tests.rs +expression: output +--- + + + + + + + ╭─────────────────────────────────────────────── Edit - ───────────────────────────────────────────────╮ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ ╭───╮ │ + │ Monitored: │ ✔ │ │ + │ ╰───╯ │ + │ ╭───────────────────────────────╮──────────────────────────────╮ │ + │ Monitor│Lossless │ ▼ │ │ + │ │ │──────────────────────────────╯ │ + │ │ │──────────────────────────────╮ │ + │ Qual│ │ ▼ │ │ + │ │ │──────────────────────────────╯ │ + │ │ │──────────────────────────────╮ │ + │ Metad│ │ ▼ │ │ + │ │ │──────────────────────────────╯ │ + │ │ │──────────────────────────────╮ │ + │ │ │ │ │ + │ │ │──────────────────────────────╯ │ + │ │ │──────────────────────────────╮ │ + │ │ │ │ │ + │ ╰───────────────────────────────╯──────────────────────────────╯ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │╭───────────────────────────────────────────────────╮╭──────────────────────────────────────────────────╮│ + ││ Save ││ Cancel ││ + │╰───────────────────────────────────────────────────╯╰──────────────────────────────────────────────────╯│ + ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_delete_artist_over_library.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_delete_artist_over_library.snap index 83323e6..a85e622 100644 --- a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_delete_artist_over_library.snap +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_delete_artist_over_library.snap @@ -24,7 +24,7 @@ expression: output │ │ │ │ │ ╭───╮ │ - │ Delete Artist Files: │ │ │ + │ Delete Artist Files: │ ✔ │ │ │ ╰───╯ │ │ ╭───╮ │ │ Add List Exclusion: │ │ │ -- 2.52.0 From a18b047f4f676cf6ea54ed3255d48402246342c1 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Wed, 7 Jan 2026 12:20:39 -0700 Subject: [PATCH 14/61] feat: Lidarr CLI support for listing and adding tags --- src/cli/lidarr/add_command_handler.rs | 65 +++++++++++ src/cli/lidarr/add_command_handler_tests.rs | 101 ++++++++++++++++++ src/cli/lidarr/lidarr_command_tests.rs | 35 ++++++ src/cli/lidarr/list_command_handler.rs | 9 ++ src/cli/lidarr/list_command_handler_tests.rs | 25 +++-- src/cli/lidarr/mod.rs | 12 +++ .../library/library_handler_tests.rs | 2 +- .../library/lidarr_library_network_tests.rs | 2 +- .../lidarr_network/lidarr_network_tests.rs | 6 +- 9 files changed, 246 insertions(+), 11 deletions(-) create mode 100644 src/cli/lidarr/add_command_handler.rs create mode 100644 src/cli/lidarr/add_command_handler_tests.rs diff --git a/src/cli/lidarr/add_command_handler.rs b/src/cli/lidarr/add_command_handler.rs new file mode 100644 index 0000000..5ffc0f2 --- /dev/null +++ b/src/cli/lidarr/add_command_handler.rs @@ -0,0 +1,65 @@ +use std::sync::Arc; + +use anyhow::Result; +use clap::{Subcommand, arg}; +use tokio::sync::Mutex; + +use super::LidarrCommand; +use crate::{ + app::App, + cli::{CliCommandHandler, Command}, + network::{NetworkTrait, lidarr_network::LidarrEvent}, +}; + +#[cfg(test)] +#[path = "add_command_handler_tests.rs"] +mod add_command_handler_tests; + +#[derive(Debug, Clone, PartialEq, Eq, Subcommand)] +pub enum LidarrAddCommand { + #[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: LidarrAddCommand) -> Self { + Command::Lidarr(LidarrCommand::Add(value)) + } +} + +pub(super) struct LidarrAddCommandHandler<'a, 'b> { + _app: &'a Arc>>, + command: LidarrAddCommand, + network: &'a mut dyn NetworkTrait, +} + +impl<'a, 'b> CliCommandHandler<'a, 'b, LidarrAddCommand> for LidarrAddCommandHandler<'a, 'b> { + fn with( + app: &'a Arc>>, + command: LidarrAddCommand, + network: &'a mut dyn NetworkTrait, + ) -> Self { + LidarrAddCommandHandler { + _app: app, + command, + network, + } + } + + async fn handle(self) -> Result { + let result = match self.command { + LidarrAddCommand::Tag { name } => { + let resp = self + .network + .handle_network_event(LidarrEvent::AddTag(name).into()) + .await?; + serde_json::to_string_pretty(&resp)? + } + }; + + Ok(result) + } +} diff --git a/src/cli/lidarr/add_command_handler_tests.rs b/src/cli/lidarr/add_command_handler_tests.rs new file mode 100644 index 0000000..351fee3 --- /dev/null +++ b/src/cli/lidarr/add_command_handler_tests.rs @@ -0,0 +1,101 @@ +#[cfg(test)] +mod tests { + use clap::{CommandFactory, Parser, error::ErrorKind}; + + use crate::{ + Cli, + cli::{ + Command, + lidarr::{LidarrCommand, add_command_handler::LidarrAddCommand}, + }, + }; + use pretty_assertions::assert_eq; + + #[test] + fn test_lidarr_add_command_from() { + let command = LidarrAddCommand::Tag { + name: String::new(), + }; + + let result = Command::from(command.clone()); + + assert_eq!(result, Command::Lidarr(LidarrCommand::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", "lidarr", "add", "tag"]); + + assert_err!(&result); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_add_tag_success() { + let expected_args = LidarrAddCommand::Tag { + name: "test".to_owned(), + }; + + let result = Cli::try_parse_from(["managarr", "lidarr", "add", "tag", "--name", "test"]); + + assert_ok!(&result); + + let Some(Command::Lidarr(LidarrCommand::Add(add_command))) = result.unwrap().command else { + panic!("Unexpected command type") + }; + assert_eq!(add_command, expected_args); + } + } + + mod handler { + use std::sync::Arc; + + use mockall::predicate::eq; + use serde_json::json; + use tokio::sync::Mutex; + + use crate::cli::CliCommandHandler; + use crate::cli::lidarr::add_command_handler::{LidarrAddCommand, LidarrAddCommandHandler}; + use crate::models::Serdeable; + use crate::models::lidarr_models::LidarrSerdeable; + use crate::network::lidarr_network::LidarrEvent; + use crate::{ + app::App, + network::{MockNetworkTrait, NetworkEvent}, + }; + + #[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::( + LidarrEvent::AddTag(expected_tag_name.clone()).into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Lidarr(LidarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::test_default())); + let add_tag_command = LidarrAddCommand::Tag { + name: expected_tag_name, + }; + + let result = LidarrAddCommandHandler::with(&app_arc, add_tag_command, &mut mock_network) + .handle() + .await; + + assert_ok!(&result); + } + } +} diff --git a/src/cli/lidarr/lidarr_command_tests.rs b/src/cli/lidarr/lidarr_command_tests.rs index 484c62d..4dbafb1 100644 --- a/src/cli/lidarr/lidarr_command_tests.rs +++ b/src/cli/lidarr/lidarr_command_tests.rs @@ -36,6 +36,13 @@ mod tests { assert_err!(&result); } + #[test] + fn test_lidarr_add_subcommand_requires_subcommand() { + let result = Cli::command().try_get_matches_from(["managarr", "lidarr", "add"]); + + assert_err!(&result); + } + #[test] fn test_lidarr_delete_subcommand_requires_subcommand() { let result = Cli::command().try_get_matches_from(["managarr", "lidarr", "delete"]); @@ -76,6 +83,7 @@ mod tests { use serde_json::json; use tokio::sync::Mutex; + use crate::cli::lidarr::add_command_handler::LidarrAddCommand; use crate::cli::lidarr::get_command_handler::LidarrGetCommand; use crate::cli::lidarr::refresh_command_handler::LidarrRefreshCommand; use crate::{ @@ -94,6 +102,33 @@ mod tests { network::{MockNetworkTrait, NetworkEvent, lidarr_network::LidarrEvent}, }; + #[tokio::test] + async fn test_lidarr_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::( + LidarrEvent::AddTag(expected_tag_name.clone()).into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Lidarr(LidarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::test_default())); + let add_tag_command = LidarrCommand::Add(LidarrAddCommand::Tag { + name: expected_tag_name, + }); + + let result = LidarrCliHandler::with(&app_arc, add_tag_command, &mut mock_network) + .handle() + .await; + + assert_ok!(&result); + } + #[tokio::test] async fn test_lidarr_cli_handler_delegates_get_commands_to_the_get_command_handler() { let mut mock_network = MockNetworkTrait::new(); diff --git a/src/cli/lidarr/list_command_handler.rs b/src/cli/lidarr/list_command_handler.rs index cf56ccb..cb0c1a8 100644 --- a/src/cli/lidarr/list_command_handler.rs +++ b/src/cli/lidarr/list_command_handler.rs @@ -20,6 +20,8 @@ mod list_command_handler_tests; pub enum LidarrListCommand { #[command(about = "List all artists in your Lidarr library")] Artists, + #[command(about = "List all Lidarr tags")] + Tags, } impl From for Command { @@ -56,6 +58,13 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, LidarrListCommand> for LidarrListCommandH .await?; serde_json::to_string_pretty(&resp)? } + LidarrListCommand::Tags => { + let resp = self + .network + .handle_network_event(LidarrEvent::GetTags.into()) + .await?; + serde_json::to_string_pretty(&resp)? + } }; Ok(result) diff --git a/src/cli/lidarr/list_command_handler_tests.rs b/src/cli/lidarr/list_command_handler_tests.rs index 6dfbae9..d01b7bd 100644 --- a/src/cli/lidarr/list_command_handler_tests.rs +++ b/src/cli/lidarr/list_command_handler_tests.rs @@ -18,11 +18,17 @@ mod tests { } mod cli { + use rstest::rstest; use super::*; - #[test] - fn test_list_artists_has_no_arg_requirements() { - let result = Cli::command().try_get_matches_from(["managarr", "lidarr", "list", "artists"]); + #[rstest] + fn test_list_commands_have_no_arg_requirements( + #[values( + "artists", + "tags" + )] subcommand: &str + ) { + let result = Cli::command().try_get_matches_from(["managarr", "lidarr", "list", subcommand]); assert_ok!(&result); } @@ -32,6 +38,7 @@ mod tests { use std::sync::Arc; use mockall::predicate::eq; + use rstest::rstest; use serde_json::json; use tokio::sync::Mutex; @@ -45,12 +52,18 @@ mod tests { network::{MockNetworkTrait, NetworkEvent}, }; + #[rstest] + #[case(LidarrListCommand::Artists, LidarrEvent::ListArtists)] + #[case(LidarrListCommand::Tags, LidarrEvent::GetTags)] #[tokio::test] - async fn test_handle_list_artists_command() { + async fn test_handle_list_command( + #[case] list_command: LidarrListCommand, + #[case] expected_lidarr_event: LidarrEvent + ) { let mut mock_network = MockNetworkTrait::new(); mock_network .expect_handle_network_event() - .with(eq::(LidarrEvent::ListArtists.into())) + .with(eq::(expected_lidarr_event.into())) .times(1) .returning(|_| { Ok(Serdeable::Lidarr(LidarrSerdeable::Value( @@ -60,7 +73,7 @@ mod tests { let app_arc = Arc::new(Mutex::new(App::test_default())); let result = - LidarrListCommandHandler::with(&app_arc, LidarrListCommand::Artists, &mut mock_network) + LidarrListCommandHandler::with(&app_arc, list_command, &mut mock_network) .handle() .await; diff --git a/src/cli/lidarr/mod.rs b/src/cli/lidarr/mod.rs index a7a4fcc..306051b 100644 --- a/src/cli/lidarr/mod.rs +++ b/src/cli/lidarr/mod.rs @@ -1,5 +1,6 @@ use std::sync::Arc; +use add_command_handler::{LidarrAddCommand, LidarrAddCommandHandler}; use anyhow::Result; use clap::{Subcommand, arg}; use delete_command_handler::{LidarrDeleteCommand, LidarrDeleteCommandHandler}; @@ -14,6 +15,7 @@ use crate::{app::App, network::NetworkTrait}; use super::{CliCommandHandler, Command}; +mod add_command_handler; mod delete_command_handler; mod edit_command_handler; mod get_command_handler; @@ -26,6 +28,11 @@ mod lidarr_command_tests; #[derive(Debug, Clone, PartialEq, Eq, Subcommand)] pub enum LidarrCommand { + #[command( + subcommand, + about = "Commands to add or create new resources within your Lidarr instance" + )] + Add(LidarrAddCommand), #[command( subcommand, about = "Commands to delete resources from your Lidarr instance" @@ -91,6 +98,11 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, LidarrCommand> for LidarrCliHandler<'a, ' async fn handle(self) -> Result { let result = match self.command { + LidarrCommand::Add(add_command) => { + LidarrAddCommandHandler::with(self.app, add_command, self.network) + .handle() + .await? + } LidarrCommand::Delete(delete_command) => { LidarrDeleteCommandHandler::with(self.app, delete_command, self.network) .handle() diff --git a/src/handlers/lidarr_handlers/library/library_handler_tests.rs b/src/handlers/lidarr_handlers/library/library_handler_tests.rs index 9801a8d..7678f34 100644 --- a/src/handlers/lidarr_handlers/library/library_handler_tests.rs +++ b/src/handlers/lidarr_handlers/library/library_handler_tests.rs @@ -532,7 +532,7 @@ mod tests { ActiveLidarrBlock::EditArtistSelectMonitorNewItems, ActiveLidarrBlock::EditArtistSelectQualityProfile, ActiveLidarrBlock::EditArtistTagsInput, - ActiveLidarrBlock::EditArtistPathInput, + ActiveLidarrBlock::EditArtistPathInput )] active_lidarr_block: ActiveLidarrBlock, ) { diff --git a/src/network/lidarr_network/library/lidarr_library_network_tests.rs b/src/network/lidarr_network/library/lidarr_library_network_tests.rs index ebdefaf..b0045f1 100644 --- a/src/network/lidarr_network/library/lidarr_library_network_tests.rs +++ b/src/network/lidarr_network/library/lidarr_library_network_tests.rs @@ -5,12 +5,12 @@ mod tests { }; use crate::network::NetworkResource; use crate::network::lidarr_network::LidarrEvent; + use crate::network::lidarr_network::lidarr_network_test_utils::test_utils::ARTIST_JSON; use crate::network::network_tests::test_utils::{MockServarrApi, test_network}; use bimap::BiMap; use mockito::Matcher; use pretty_assertions::assert_eq; use serde_json::{Value, json}; - use crate::network::lidarr_network::lidarr_network_test_utils::test_utils::ARTIST_JSON; #[tokio::test] async fn test_handle_list_artists_event() { diff --git a/src/network/lidarr_network/lidarr_network_tests.rs b/src/network/lidarr_network/lidarr_network_tests.rs index 0e487a3..ee96299 100644 --- a/src/network/lidarr_network/lidarr_network_tests.rs +++ b/src/network/lidarr_network/lidarr_network_tests.rs @@ -1,7 +1,8 @@ #[cfg(test)] mod tests { - use std::sync::Arc; + use crate::app::App; use crate::models::lidarr_models::{LidarrSerdeable, MetadataProfile}; + use crate::models::servarr_data::lidarr::modals::EditArtistModal; use crate::models::servarr_models::{QualityProfile, Tag}; use crate::network::network_tests::test_utils::{MockServarrApi, test_network}; use crate::network::{NetworkEvent, NetworkResource, lidarr_network::LidarrEvent}; @@ -9,9 +10,8 @@ mod tests { use pretty_assertions::{assert_eq, assert_str_eq}; use rstest::rstest; use serde_json::json; + use std::sync::Arc; use tokio::sync::Mutex; - use crate::app::App; - use crate::models::servarr_data::lidarr::modals::EditArtistModal; #[rstest] fn test_resource_artist( -- 2.52.0 From a8609e08c590a9e6eec7a055738a6555d54970bd Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Wed, 7 Jan 2026 12:50:23 -0700 Subject: [PATCH 15/61] feat: CLI support for deleting a tag in Lidarr --- src/cli/lidarr/delete_command_handler.rs | 12 +++++ .../lidarr/delete_command_handler_tests.rs | 52 +++++++++++++++++++ src/cli/lidarr/list_command_handler_tests.rs | 22 +++----- .../lidarr_network/lidarr_network_tests.rs | 27 ++++++++++ src/network/lidarr_network/mod.rs | 26 +++++++++- 5 files changed, 124 insertions(+), 15 deletions(-) diff --git a/src/cli/lidarr/delete_command_handler.rs b/src/cli/lidarr/delete_command_handler.rs index 131b5aa..9a723cb 100644 --- a/src/cli/lidarr/delete_command_handler.rs +++ b/src/cli/lidarr/delete_command_handler.rs @@ -28,6 +28,11 @@ pub enum LidarrDeleteCommand { #[arg(long, help = "Add a list exclusion for this artist")] 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)] + tag_id: i64, + }, } impl From for Command { @@ -73,6 +78,13 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, LidarrDeleteCommand> for LidarrDeleteComm .await?; serde_json::to_string_pretty(&resp)? } + LidarrDeleteCommand::Tag { tag_id } => { + let resp = self + .network + .handle_network_event(LidarrEvent::DeleteTag(tag_id).into()) + .await?; + serde_json::to_string_pretty(&resp)? + } }; Ok(result) diff --git a/src/cli/lidarr/delete_command_handler_tests.rs b/src/cli/lidarr/delete_command_handler_tests.rs index dda5a61..20f4460 100644 --- a/src/cli/lidarr/delete_command_handler_tests.rs +++ b/src/cli/lidarr/delete_command_handler_tests.rs @@ -85,6 +85,32 @@ mod tests { }; assert_eq!(delete_command, expected_args); } + + #[test] + fn test_delete_tag_requires_arguments() { + let result = Cli::command().try_get_matches_from(["managarr", "lidarr", "delete", "tag"]); + + assert_err!(&result); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_delete_tag_success() { + let expected_args = LidarrDeleteCommand::Tag { tag_id: 1 }; + + let result = Cli::try_parse_from(["managarr", "lidarr", "delete", "tag", "--tag-id", "1"]); + + assert_ok!(&result); + + let Some(Command::Lidarr(LidarrCommand::Delete(delete_command))) = result.unwrap().command + else { + panic!("Unexpected command type"); + }; + assert_eq!(delete_command, expected_args); + } } mod handler { @@ -140,5 +166,31 @@ mod tests { assert_ok!(&result); } + + #[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::( + LidarrEvent::DeleteTag(expected_tag_id).into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Lidarr(LidarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::test_default())); + let delete_tag_command = LidarrDeleteCommand::Tag { tag_id: 1 }; + + let result = + LidarrDeleteCommandHandler::with(&app_arc, delete_tag_command, &mut mock_network) + .handle() + .await; + + assert_ok!(&result); + } } } diff --git a/src/cli/lidarr/list_command_handler_tests.rs b/src/cli/lidarr/list_command_handler_tests.rs index d01b7bd..9402480 100644 --- a/src/cli/lidarr/list_command_handler_tests.rs +++ b/src/cli/lidarr/list_command_handler_tests.rs @@ -18,16 +18,11 @@ mod tests { } mod cli { - use rstest::rstest; use super::*; + use rstest::rstest; #[rstest] - fn test_list_commands_have_no_arg_requirements( - #[values( - "artists", - "tags" - )] subcommand: &str - ) { + fn test_list_commands_have_no_arg_requirements(#[values("artists", "tags")] subcommand: &str) { let result = Cli::command().try_get_matches_from(["managarr", "lidarr", "list", subcommand]); assert_ok!(&result); @@ -53,12 +48,12 @@ mod tests { }; #[rstest] - #[case(LidarrListCommand::Artists, LidarrEvent::ListArtists)] - #[case(LidarrListCommand::Tags, LidarrEvent::GetTags)] + #[case(LidarrListCommand::Artists, LidarrEvent::ListArtists)] + #[case(LidarrListCommand::Tags, LidarrEvent::GetTags)] #[tokio::test] async fn test_handle_list_command( #[case] list_command: LidarrListCommand, - #[case] expected_lidarr_event: LidarrEvent + #[case] expected_lidarr_event: LidarrEvent, ) { let mut mock_network = MockNetworkTrait::new(); mock_network @@ -72,10 +67,9 @@ mod tests { }); let app_arc = Arc::new(Mutex::new(App::test_default())); - let result = - LidarrListCommandHandler::with(&app_arc, list_command, &mut mock_network) - .handle() - .await; + let result = LidarrListCommandHandler::with(&app_arc, list_command, &mut mock_network) + .handle() + .await; assert_ok!(&result); } diff --git a/src/network/lidarr_network/lidarr_network_tests.rs b/src/network/lidarr_network/lidarr_network_tests.rs index ee96299..3a2dee4 100644 --- a/src/network/lidarr_network/lidarr_network_tests.rs +++ b/src/network/lidarr_network/lidarr_network_tests.rs @@ -25,6 +25,18 @@ mod tests { assert_str_eq!(event.resource(), "/artist"); } + #[rstest] + fn test_resource_tag( + #[values( + LidarrEvent::AddTag(String::new()), + LidarrEvent::DeleteTag(0), + LidarrEvent::GetTags + )] + event: LidarrEvent, + ) { + assert_str_eq!(event.resource(), "/tag"); + } + #[rstest] fn test_resource_config( #[values(LidarrEvent::GetHostConfig, LidarrEvent::GetSecurityConfig)] event: LidarrEvent, @@ -208,6 +220,21 @@ mod tests { ); } + #[tokio::test] + async fn test_handle_delete_lidarr_tag_event() { + let (mock, app, _server) = MockServarrApi::delete() + .path("/1") + .build_for(LidarrEvent::DeleteTag(1)) + .await; + app.lock().await.server_tabs.set_index(2); + let mut network = test_network(&app); + + let result = network.handle_lidarr_event(LidarrEvent::DeleteTag(1)).await; + + mock.assert_async().await; + assert!(result.is_ok()); + } + #[tokio::test] async fn test_extract_and_add_lidarr_tag_ids_vec() { let app_arc = Arc::new(Mutex::new(App::test_default())); diff --git a/src/network/lidarr_network/mod.rs b/src/network/lidarr_network/mod.rs index 068a090..0ad2a62 100644 --- a/src/network/lidarr_network/mod.rs +++ b/src/network/lidarr_network/mod.rs @@ -25,6 +25,7 @@ pub mod lidarr_network_test_utils; pub enum LidarrEvent { AddTag(String), DeleteArtist(DeleteArtistParams), + DeleteTag(i64), EditArtist(EditArtistParams), GetArtistDetails(i64), GetDiskSpace, @@ -45,7 +46,7 @@ pub enum LidarrEvent { impl NetworkResource for LidarrEvent { fn resource(&self) -> &'static str { match &self { - LidarrEvent::AddTag(_) | LidarrEvent::GetTags => "/tag", + LidarrEvent::AddTag(_) | LidarrEvent::DeleteTag(_) | LidarrEvent::GetTags => "/tag", LidarrEvent::DeleteArtist(_) | LidarrEvent::EditArtist(_) | LidarrEvent::GetArtistDetails(_) @@ -80,6 +81,10 @@ impl Network<'_, '_> { LidarrEvent::DeleteArtist(params) => { self.delete_artist(params).await.map(LidarrSerdeable::from) } + LidarrEvent::DeleteTag(tag_id) => self + .delete_lidarr_tag(tag_id) + .await + .map(LidarrSerdeable::from), LidarrEvent::GetArtistDetails(artist_id) => self .get_artist_details(artist_id) .await @@ -213,6 +218,25 @@ impl Network<'_, '_> { .await } + async fn delete_lidarr_tag(&mut self, id: i64) -> Result<()> { + info!("Deleting Lidarr tag with ID: {id}"); + let event = LidarrEvent::DeleteTag(id); + + let request_props = self + .request_props_from( + event, + RequestMethod::Delete, + None::<()>, + Some(format!("/{id}")), + None, + ) + .await; + + self + .handle_request::<(), ()>(request_props, |_, _| ()) + .await + } + pub(in crate::network::lidarr_network) async fn extract_and_add_lidarr_tag_ids_vec( &mut self, edit_tags: &str, -- 2.52.0 From 45c61369c82c42b86f0235fffb2cc6025bd318df Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Wed, 7 Jan 2026 13:08:23 -0700 Subject: [PATCH 16/61] feat: Improved CLI readability by creating a separate Global Options section for global flags --- src/main.rs | 29 +++++++++++++++++++++-------- 1 file changed, 21 insertions(+), 8 deletions(-) diff --git a/src/main.rs b/src/main.rs index 9324092..4baa095 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3,12 +3,15 @@ extern crate assertables; use anyhow::Result; -use clap::{CommandFactory, Parser, crate_authors, crate_description, crate_name, crate_version}; +use clap::{ + Args, CommandFactory, Parser, crate_authors, crate_description, crate_name, crate_version, +}; use clap_complete::generate; use crossterm::execute; use crossterm::terminal::{ EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode, }; +use indoc::indoc; use log::{debug, error, warn}; use network::NetworkTrait; use ratatui::Terminal; @@ -64,6 +67,13 @@ mod utils; struct Cli { #[command(subcommand)] command: Option, + #[command(flatten)] + global: GlobalOpts, +} + +#[derive(Args, Debug)] +#[command(next_help_heading = "Global Options")] +struct GlobalOpts { #[arg( long, global = true, @@ -98,9 +108,12 @@ struct Cli { #[arg( long, global = true, - help = "For multi-instance configurations, you need to specify the name of the instance configuration that you want to use. - This is useful when you have multiple instances of the same Servarr defined in your config file. - By default, if left empty, the first configured Servarr instance listed in the config file will be used." + help = indoc!{" + For multi-instance configurations, you need to specify the name of the instance configuration that you want to use. + + This is useful when you have multiple instances of the same Servarr defined in your config file. + By default, if left empty, the first configured Servarr instance listed in the config file will be used. + "} )] servarr_name: Option, } @@ -114,13 +127,13 @@ async fn main() -> Result<()> { let running = Arc::new(AtomicBool::new(true)); let r = running.clone(); let args = Cli::parse(); - let mut config = if let Some(ref config_file) = args.config_file { + let mut config = if let Some(ref config_file) = args.global.config_file { load_config(config_file.to_str().expect("Invalid config file specified"))? } else { confy::load("managarr", "config")? }; let theme_name = config.theme.clone(); - let spinner_disabled = args.disable_spinner; + let spinner_disabled = args.global.disable_spinner; debug!("Managarr loaded using config: {config:?}"); config.validate(); config.post_process_initialization(); @@ -165,8 +178,8 @@ async fn main() -> Result<()> { }); start_ui( &app, - &args.themes_file, - args.theme.unwrap_or(theme_name.unwrap_or_default()), + &args.global.themes_file, + args.global.theme.unwrap_or(theme_name.unwrap_or_default()), ) .await?; } -- 2.52.0 From 9cc3ccb41939b1ccb257f48990969b0bb084d2c6 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Wed, 7 Jan 2026 13:15:50 -0700 Subject: [PATCH 17/61] feat: Lidarr CLI commands to list quality profiles and metadata profiles --- src/cli/lidarr/list_command_handler.rs | 18 ++++++++++++++++++ src/cli/lidarr/list_command_handler_tests.rs | 6 +++++- 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/src/cli/lidarr/list_command_handler.rs b/src/cli/lidarr/list_command_handler.rs index cb0c1a8..9d0b176 100644 --- a/src/cli/lidarr/list_command_handler.rs +++ b/src/cli/lidarr/list_command_handler.rs @@ -20,6 +20,10 @@ mod list_command_handler_tests; pub enum LidarrListCommand { #[command(about = "List all artists in your Lidarr library")] Artists, + #[command(about = "List all Lidarr metadata profiles")] + MetadataProfiles, + #[command(about = "List all Lidarr quality profiles")] + QualityProfiles, #[command(about = "List all Lidarr tags")] Tags, } @@ -58,6 +62,20 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, LidarrListCommand> for LidarrListCommandH .await?; serde_json::to_string_pretty(&resp)? } + LidarrListCommand::MetadataProfiles => { + let resp = self + .network + .handle_network_event(LidarrEvent::GetMetadataProfiles.into()) + .await?; + serde_json::to_string_pretty(&resp)? + } + LidarrListCommand::QualityProfiles => { + let resp = self + .network + .handle_network_event(LidarrEvent::GetQualityProfiles.into()) + .await?; + serde_json::to_string_pretty(&resp)? + } LidarrListCommand::Tags => { let resp = self .network diff --git a/src/cli/lidarr/list_command_handler_tests.rs b/src/cli/lidarr/list_command_handler_tests.rs index 9402480..3933cb6 100644 --- a/src/cli/lidarr/list_command_handler_tests.rs +++ b/src/cli/lidarr/list_command_handler_tests.rs @@ -22,7 +22,9 @@ mod tests { use rstest::rstest; #[rstest] - fn test_list_commands_have_no_arg_requirements(#[values("artists", "tags")] subcommand: &str) { + fn test_list_commands_have_no_arg_requirements( + #[values("artists", "metadata-profiles", "quality-profiles", "tags")] subcommand: &str, + ) { let result = Cli::command().try_get_matches_from(["managarr", "lidarr", "list", subcommand]); assert_ok!(&result); @@ -49,6 +51,8 @@ mod tests { #[rstest] #[case(LidarrListCommand::Artists, LidarrEvent::ListArtists)] + #[case(LidarrListCommand::MetadataProfiles, LidarrEvent::GetMetadataProfiles)] + #[case(LidarrListCommand::QualityProfiles, LidarrEvent::GetQualityProfiles)] #[case(LidarrListCommand::Tags, LidarrEvent::GetTags)] #[tokio::test] async fn test_handle_list_command( -- 2.52.0 From 60c4cf1098f1ae2fd0ff1ae4ed51d2e21f5f4719 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Wed, 7 Jan 2026 14:00:02 -0700 Subject: [PATCH 18/61] fix: Fixed a bug in all Servarr implementations to not try to get the current selection of a search table when an error is returned from the API --- src/models/mod.rs | 4 ++++ src/models/model_tests.rs | 5 +++++ src/ui/radarr_ui/library/add_movie_ui.rs | 2 +- src/ui/sonarr_ui/library/add_series_ui.rs | 2 +- 4 files changed, 11 insertions(+), 2 deletions(-) diff --git a/src/models/mod.rs b/src/models/mod.rs index 3eea7c3..b887941 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -272,6 +272,10 @@ impl HorizontallyScrollableText { } } } + + pub fn is_empty(&self) -> bool { + self.text.is_empty() + } } #[derive(Clone, PartialEq, Eq, Debug)] diff --git a/src/models/model_tests.rs b/src/models/model_tests.rs index 1b8dc3e..95b6dd8 100644 --- a/src/models/model_tests.rs +++ b/src/models/model_tests.rs @@ -176,6 +176,11 @@ mod tests { assert_str_eq!(horizontally_scrollable_text.text, test_text); } + #[test] + fn test_horizontally_scrollable_text_is_empty() { + assert_is_empty!(HorizontallyScrollableText::from("")) + } + #[test] fn test_horizontally_scrollable_text_scroll_text_left() { let horizontally_scrollable_text = HorizontallyScrollableText::from("Test string"); diff --git a/src/ui/radarr_ui/library/add_movie_ui.rs b/src/ui/radarr_ui/library/add_movie_ui.rs index f3ad937..eef00b3 100644 --- a/src/ui/radarr_ui/library/add_movie_ui.rs +++ b/src/ui/radarr_ui/library/add_movie_ui.rs @@ -70,7 +70,7 @@ impl DrawUi for AddMovieUi { fn draw_add_movie_search(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { let is_loading = app.is_loading || app.data.radarr_data.add_searched_movies.is_none(); let current_selection = - if let Some(add_searched_movies) = app.data.radarr_data.add_searched_movies.as_ref() { + if let Some(add_searched_movies) = app.data.radarr_data.add_searched_movies.as_ref() && app.error.is_empty() { add_searched_movies.current_selection().clone() } else { AddMovieSearchResult::default() diff --git a/src/ui/sonarr_ui/library/add_series_ui.rs b/src/ui/sonarr_ui/library/add_series_ui.rs index 6d09c02..8fd733c 100644 --- a/src/ui/sonarr_ui/library/add_series_ui.rs +++ b/src/ui/sonarr_ui/library/add_series_ui.rs @@ -65,7 +65,7 @@ impl DrawUi for AddSeriesUi { fn draw_add_series_search(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { let is_loading = app.is_loading || app.data.sonarr_data.add_searched_series.is_none(); let current_selection = - if let Some(add_searched_series) = app.data.sonarr_data.add_searched_series.as_ref() { + if let Some(add_searched_series) = app.data.sonarr_data.add_searched_series.as_ref() && app.error.is_empty() { add_searched_series.current_selection().clone() } else { AddSeriesSearchResult::default() -- 2.52.0 From 64d8c658316f85573af9a6b69c2061ee3bfcdca6 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Wed, 7 Jan 2026 14:09:12 -0700 Subject: [PATCH 19/61] fix: Prevented additional empty slice errors in indexer tables --- src/models/mod.rs | 4 ---- src/models/model_tests.rs | 5 ----- src/ui/radarr_ui/indexers/test_all_indexers_ui.rs | 2 +- src/ui/radarr_ui/library/add_movie_ui.rs | 14 ++++++++------ src/ui/sonarr_ui/indexers/test_all_indexers_ui.rs | 2 +- src/ui/sonarr_ui/library/add_series_ui.rs | 14 ++++++++------ 6 files changed, 18 insertions(+), 23 deletions(-) diff --git a/src/models/mod.rs b/src/models/mod.rs index b887941..3eea7c3 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -272,10 +272,6 @@ impl HorizontallyScrollableText { } } } - - pub fn is_empty(&self) -> bool { - self.text.is_empty() - } } #[derive(Clone, PartialEq, Eq, Debug)] diff --git a/src/models/model_tests.rs b/src/models/model_tests.rs index 95b6dd8..1b8dc3e 100644 --- a/src/models/model_tests.rs +++ b/src/models/model_tests.rs @@ -176,11 +176,6 @@ mod tests { assert_str_eq!(horizontally_scrollable_text.text, test_text); } - #[test] - fn test_horizontally_scrollable_text_is_empty() { - assert_is_empty!(HorizontallyScrollableText::from("")) - } - #[test] fn test_horizontally_scrollable_text_scroll_text_left() { let horizontally_scrollable_text = HorizontallyScrollableText::from("Test string"); 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 734fc15..ac1a912 100644 --- a/src/ui/radarr_ui/indexers/test_all_indexers_ui.rs +++ b/src/ui/radarr_ui/indexers/test_all_indexers_ui.rs @@ -35,7 +35,7 @@ fn draw_test_all_indexers_test_results(f: &mut Frame<'_>, app: &mut App<'_>, are let block = title_block("Test All Indexers"); let current_selection = - if let Some(test_all_results) = app.data.radarr_data.indexer_test_all_results.as_ref() { + if let Some(test_all_results) = app.data.radarr_data.indexer_test_all_results.as_ref() && !test_all_results.is_empty() { test_all_results.current_selection().clone() } else { IndexerTestResultModalItem::default() diff --git a/src/ui/radarr_ui/library/add_movie_ui.rs b/src/ui/radarr_ui/library/add_movie_ui.rs index eef00b3..ee2becd 100644 --- a/src/ui/radarr_ui/library/add_movie_ui.rs +++ b/src/ui/radarr_ui/library/add_movie_ui.rs @@ -69,12 +69,14 @@ impl DrawUi for AddMovieUi { fn draw_add_movie_search(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { let is_loading = app.is_loading || app.data.radarr_data.add_searched_movies.is_none(); - let current_selection = - if let Some(add_searched_movies) = app.data.radarr_data.add_searched_movies.as_ref() && app.error.is_empty() { - add_searched_movies.current_selection().clone() - } else { - AddMovieSearchResult::default() - }; + let current_selection = if let Some(add_searched_movies) = + app.data.radarr_data.add_searched_movies.as_ref() + && !add_searched_movies.is_empty() + { + add_searched_movies.current_selection().clone() + } else { + AddMovieSearchResult::default() + }; let [search_box_area, results_area] = Layout::vertical([Constraint::Length(3), Constraint::Fill(0)]) diff --git a/src/ui/sonarr_ui/indexers/test_all_indexers_ui.rs b/src/ui/sonarr_ui/indexers/test_all_indexers_ui.rs index 8b0bf20..bfed06a 100644 --- a/src/ui/sonarr_ui/indexers/test_all_indexers_ui.rs +++ b/src/ui/sonarr_ui/indexers/test_all_indexers_ui.rs @@ -34,7 +34,7 @@ impl DrawUi for TestAllIndexersUi { fn draw_test_all_indexers_test_results(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { let is_loading = app.is_loading || app.data.sonarr_data.indexer_test_all_results.is_none(); let current_selection = - if let Some(test_all_results) = app.data.sonarr_data.indexer_test_all_results.as_ref() { + if let Some(test_all_results) = app.data.sonarr_data.indexer_test_all_results.as_ref() && !test_all_results.is_empty() { test_all_results.current_selection().clone() } else { IndexerTestResultModalItem::default() diff --git a/src/ui/sonarr_ui/library/add_series_ui.rs b/src/ui/sonarr_ui/library/add_series_ui.rs index 8fd733c..3efdc9c 100644 --- a/src/ui/sonarr_ui/library/add_series_ui.rs +++ b/src/ui/sonarr_ui/library/add_series_ui.rs @@ -64,12 +64,14 @@ impl DrawUi for AddSeriesUi { fn draw_add_series_search(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { let is_loading = app.is_loading || app.data.sonarr_data.add_searched_series.is_none(); - let current_selection = - if let Some(add_searched_series) = app.data.sonarr_data.add_searched_series.as_ref() && app.error.is_empty() { - add_searched_series.current_selection().clone() - } else { - AddSeriesSearchResult::default() - }; + let current_selection = if let Some(add_searched_series) = + app.data.sonarr_data.add_searched_series.as_ref() + && !add_searched_series.is_empty() + { + add_searched_series.current_selection().clone() + } else { + AddSeriesSearchResult::default() + }; let [search_box_area, results_area] = Layout::vertical([Constraint::Length(3), Constraint::Fill(0)]) -- 2.52.0 From d3947d9e151b9b29420d351ec8e70ea64b21b30d Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Wed, 7 Jan 2026 14:58:32 -0700 Subject: [PATCH 20/61] fix: Improved fault tolerance for search result tables and test all indexer results tables --- src/network/radarr_network/indexers/mod.rs | 10 ++++-- .../indexers/radarr_indexers_network_tests.rs | 25 +++++++++++++++ src/network/radarr_network/library/mod.rs | 10 ++++-- .../library/radarr_library_network_tests.rs | 32 +++++++++++++++++-- src/network/sonarr_network/indexers/mod.rs | 10 ++++-- .../indexers/sonarr_indexers_network_tests.rs | 25 +++++++++++++++ .../sonarr_network/library/series/mod.rs | 20 ++++++++---- .../series/sonarr_series_network_tests.rs | 29 +++++++++++++++++ 8 files changed, 145 insertions(+), 16 deletions(-) diff --git a/src/network/radarr_network/indexers/mod.rs b/src/network/radarr_network/indexers/mod.rs index e9c67ee..a2334f5 100644 --- a/src/network/radarr_network/indexers/mod.rs +++ b/src/network/radarr_network/indexers/mod.rs @@ -368,7 +368,7 @@ impl Network<'_, '_> { .await; request_props.ignore_status_code = true; - self + let result = self .handle_request::<(), Vec>(request_props, |test_results, mut app| { let mut test_all_indexer_results = StatefulTable::default(); let indexers = app.data.radarr_data.indexers.items.clone(); @@ -403,6 +403,12 @@ impl Network<'_, '_> { test_all_indexer_results.set_items(modal_test_results); app.data.radarr_data.indexer_test_all_results = Some(test_all_indexer_results); }) - .await + .await; + + if result.is_err() { + self.app.lock().await.data.radarr_data.indexer_test_all_results = Some(StatefulTable::default()); + } + + result } } diff --git a/src/network/radarr_network/indexers/radarr_indexers_network_tests.rs b/src/network/radarr_network/indexers/radarr_indexers_network_tests.rs index 4c0c228..bc545d5 100644 --- a/src/network/radarr_network/indexers/radarr_indexers_network_tests.rs +++ b/src/network/radarr_network/indexers/radarr_indexers_network_tests.rs @@ -925,4 +925,29 @@ mod tests { ); assert_eq!(results, response); } + + #[tokio::test] + async fn test_handle_test_all_radarr_indexers_event_sets_empty_table_on_api_error() { + let (async_server, app, _server) = MockServarrApi::post() + .status(500) + .build_for(RadarrEvent::TestAllIndexers) + .await; + let mut network = test_network(&app); + + let result = network + .handle_radarr_event(RadarrEvent::TestAllIndexers) + .await; + + async_server.assert_async().await; + assert_err!(result); + assert_some!( + &app + .lock() + .await + .data + .radarr_data + .indexer_test_all_results + ); + assert_is_empty!(app.lock().await.data.radarr_data.indexer_test_all_results.as_ref().unwrap()); + } } diff --git a/src/network/radarr_network/library/mod.rs b/src/network/radarr_network/library/mod.rs index 6d08957..c68a63c 100644 --- a/src/network/radarr_network/library/mod.rs +++ b/src/network/radarr_network/library/mod.rs @@ -498,7 +498,7 @@ impl Network<'_, '_> { ) .await; - self + let result = self .handle_request::<(), Vec>(request_props, |movie_vec, mut app| { if movie_vec.is_empty() { app.pop_and_push_navigation_stack(ActiveRadarrBlock::AddMovieEmptySearchResults.into()); @@ -511,7 +511,13 @@ impl Network<'_, '_> { app.data.radarr_data.add_searched_movies = Some(add_searched_movies); } }) - .await + .await; + + if result.is_err() { + self.app.lock().await.data.radarr_data.add_searched_movies = Some(StatefulTable::default()); + } + + result } pub(in crate::network) async fn toggle_movie_monitoring(&mut self, movie_id: i64) -> Result<()> { diff --git a/src/network/radarr_network/library/radarr_library_network_tests.rs b/src/network/radarr_network/library/radarr_library_network_tests.rs index 24288c2..f91f2c9 100644 --- a/src/network/radarr_network/library/radarr_library_network_tests.rs +++ b/src/network/radarr_network/library/radarr_library_network_tests.rs @@ -981,14 +981,13 @@ mod tests { ); async_server.assert_async().await; - assert!( - app_arc + assert_none!( + &app_arc .lock() .await .data .radarr_data .add_searched_movies - .is_none() ); assert_eq!( app_arc.lock().await.get_current_route(), @@ -996,6 +995,33 @@ mod tests { ); } + #[tokio::test] + async fn test_handle_search_new_movie_event_sets_empty_table_on_api_error() { + let (async_server, app_arc, _server) = MockServarrApi::get() + .returns(json!([])) + .status(500) + .query("term=test%20term") + .build_for(RadarrEvent::SearchNewMovie("test term".into())) + .await; + let mut network = test_network(&app_arc); + + let result = network + .handle_radarr_event(RadarrEvent::SearchNewMovie("test term".into())) + .await; + + async_server.assert_async().await; + assert_err!(result); + assert_some!( + &app_arc + .lock() + .await + .data + .radarr_data + .add_searched_movies + ); + assert_is_empty!(app_arc.lock().await.data.radarr_data.add_searched_movies.as_ref().unwrap()); + } + #[tokio::test] async fn test_handle_toggle_movie_monitoring_event() { let mut expected_body: Value = serde_json::from_str(MOVIE_JSON).unwrap(); diff --git a/src/network/sonarr_network/indexers/mod.rs b/src/network/sonarr_network/indexers/mod.rs index 7200d47..576a8c7 100644 --- a/src/network/sonarr_network/indexers/mod.rs +++ b/src/network/sonarr_network/indexers/mod.rs @@ -366,7 +366,7 @@ impl Network<'_, '_> { .await; request_props.ignore_status_code = true; - self + let result = 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(); @@ -401,6 +401,12 @@ impl Network<'_, '_> { test_all_indexer_results.set_items(modal_test_results); app.data.sonarr_data.indexer_test_all_results = Some(test_all_indexer_results); }) - .await + .await; + + if result.is_err() { + self.app.lock().await.data.sonarr_data.indexer_test_all_results = Some(StatefulTable::default()); + } + + result } } diff --git a/src/network/sonarr_network/indexers/sonarr_indexers_network_tests.rs b/src/network/sonarr_network/indexers/sonarr_indexers_network_tests.rs index 3bd86bd..f5512cf 100644 --- a/src/network/sonarr_network/indexers/sonarr_indexers_network_tests.rs +++ b/src/network/sonarr_network/indexers/sonarr_indexers_network_tests.rs @@ -884,4 +884,29 @@ mod tests { ); assert_eq!(results, response); } + + #[tokio::test] + async fn test_handle_test_all_sonarr_indexers_event_sets_empty_table_on_api_error() { + let (async_server, app, _server) = MockServarrApi::post() + .status(500) + .build_for(SonarrEvent::TestAllIndexers) + .await; + let mut network = test_network(&app); + app.lock().await.server_tabs.next(); + + let result = network + .handle_sonarr_event(SonarrEvent::TestAllIndexers) + .await; + + async_server.assert_async().await; + assert_err!(result); + let app = app.lock().await; + assert_some!( + &app + .data + .sonarr_data + .indexer_test_all_results + ); + assert_is_empty!(app.data.sonarr_data.indexer_test_all_results.as_ref().unwrap()); + } } diff --git a/src/network/sonarr_network/library/series/mod.rs b/src/network/sonarr_network/library/series/mod.rs index 3c532c8..151cbe6 100644 --- a/src/network/sonarr_network/library/series/mod.rs +++ b/src/network/sonarr_network/library/series/mod.rs @@ -362,20 +362,26 @@ impl Network<'_, '_> { ) .await; - self + let result = 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() + } else if let Some(add_searched_series) = app.data.sonarr_data.add_searched_series.as_mut() { - add_searched_seriess.set_items(series_vec); + add_searched_series.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); + let mut add_searched_series = StatefulTable::default(); + add_searched_series.set_items(series_vec); + app.data.sonarr_data.add_searched_series = Some(add_searched_series); } }) - .await + .await; + + if result.is_err() { + self.app.lock().await.data.sonarr_data.add_searched_series = Some(StatefulTable::default()); + } + + result } pub(in crate::network::sonarr_network) async fn trigger_automatic_series_search( diff --git a/src/network/sonarr_network/library/series/sonarr_series_network_tests.rs b/src/network/sonarr_network/library/series/sonarr_series_network_tests.rs index fabd110..d4b83f3 100644 --- a/src/network/sonarr_network/library/series/sonarr_series_network_tests.rs +++ b/src/network/sonarr_network/library/series/sonarr_series_network_tests.rs @@ -943,6 +943,35 @@ mod tests { ); } + #[tokio::test] + async fn test_handle_search_new_series_event_sets_empty_table_on_api_error() { + let (async_server, app, _server) = MockServarrApi::get() + .status(500) + .query("term=test%20term") + .build_for(SonarrEvent::SearchNewSeries("test term".into())) + .await; + app.lock().await.server_tabs.next(); + let mut network = test_network(&app); + + let result = + network + .handle_sonarr_event(SonarrEvent::SearchNewSeries("test term".into())) + .await; + + async_server.assert_async().await; + assert_err!(result); + let app = app.lock().await; + assert_some!( + &app + .data + .sonarr_data + .add_searched_series + ); + assert_is_empty!( + app.data.sonarr_data.add_searched_series.as_ref().unwrap() + ); + } + #[tokio::test] async fn test_handle_trigger_automatic_series_search_event() { let (async_server, app, _server) = MockServarrApi::post() -- 2.52.0 From 243de47cae5800b50479806d838a123c0f66d144 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Wed, 7 Jan 2026 15:53:18 -0700 Subject: [PATCH 21/61] feat: Initial Lidarr support for searching for new artists --- src/app/lidarr/lidarr_context_clues.rs | 17 +- src/app/lidarr/lidarr_context_clues_tests.rs | 51 ++- src/app/lidarr/lidarr_tests.rs | 31 +- src/app/lidarr/mod.rs | 18 + src/cli/lidarr/lidarr_command_tests.rs | 51 +++ src/cli/lidarr/mod.rs | 16 + .../library/add_artist_handler.rs | 181 +++++++++ .../library/add_artist_handler_tests.rs | 356 ++++++++++++++++++ .../library/library_handler_tests.rs | 34 +- src/handlers/lidarr_handlers/library/mod.rs | 18 +- src/models/lidarr_models.rs | 14 + src/models/lidarr_models_tests.rs | 72 +++- src/models/servarr_data/lidarr/lidarr_data.rs | 24 +- .../servarr_data/lidarr/lidarr_data_tests.rs | 12 +- .../library/lidarr_library_network_tests.rs | 87 ++++- src/network/lidarr_network/library/mod.rs | 46 ++- .../lidarr_network_test_utils.rs | 31 +- src/network/lidarr_network/mod.rs | 5 + src/network/radarr_network/indexers/mod.rs | 10 +- .../indexers/radarr_indexers_network_tests.rs | 8 +- .../library/radarr_library_network_tests.rs | 23 +- src/network/sonarr_network/indexers/mod.rs | 8 +- .../indexers/sonarr_indexers_network_tests.rs | 8 +- .../series/sonarr_series_network_tests.rs | 19 +- src/ui/lidarr_ui/library/add_artist_ui.rs | 151 ++++++++ .../lidarr_ui/library/add_artist_ui_tests.rs | 61 +++ src/ui/lidarr_ui/library/library_ui_tests.rs | 3 +- src/ui/lidarr_ui/library/mod.rs | 6 +- ...ot_tests__AddArtistEmptySearchResults.snap | 47 +++ ..._snapshot_tests__AddArtistSearchInput.snap | 47 +++ ...napshot_tests__AddArtistSearchResults.snap | 47 +++ ...artist_ui_AddArtistEmptySearchResults.snap | 47 +++ ...s__add_artist_ui_AddArtistSearchInput.snap | 47 +++ ..._add_artist_ui_AddArtistSearchResults.snap | 47 +++ ..._artist_ui_renders_loading_for_search.snap | 47 +++ .../indexers/test_all_indexers_ui.rs | 14 +- .../indexers/test_all_indexers_ui.rs | 14 +- 37 files changed, 1646 insertions(+), 72 deletions(-) create mode 100644 src/handlers/lidarr_handlers/library/add_artist_handler.rs create mode 100644 src/handlers/lidarr_handlers/library/add_artist_handler_tests.rs create mode 100644 src/ui/lidarr_ui/library/add_artist_ui.rs create mode 100644 src/ui/lidarr_ui/library/add_artist_ui_tests.rs create mode 100644 src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__add_artist_ui__add_artist_ui_tests__tests__snapshot_tests__AddArtistEmptySearchResults.snap create mode 100644 src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__add_artist_ui__add_artist_ui_tests__tests__snapshot_tests__AddArtistSearchInput.snap create mode 100644 src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__add_artist_ui__add_artist_ui_tests__tests__snapshot_tests__AddArtistSearchResults.snap create mode 100644 src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__add_artist_ui__add_artist_ui_tests__tests__snapshot_tests__add_artist_ui_AddArtistEmptySearchResults.snap create mode 100644 src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__add_artist_ui__add_artist_ui_tests__tests__snapshot_tests__add_artist_ui_AddArtistSearchInput.snap create mode 100644 src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__add_artist_ui__add_artist_ui_tests__tests__snapshot_tests__add_artist_ui_AddArtistSearchResults.snap create mode 100644 src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__add_artist_ui__add_artist_ui_tests__tests__snapshot_tests__add_artist_ui_renders_loading_for_search.snap diff --git a/src/app/lidarr/lidarr_context_clues.rs b/src/app/lidarr/lidarr_context_clues.rs index b33782b..ff3409c 100644 --- a/src/app/lidarr/lidarr_context_clues.rs +++ b/src/app/lidarr/lidarr_context_clues.rs @@ -1,13 +1,15 @@ use crate::app::App; -use crate::app::context_clues::{ContextClue, ContextClueProvider}; +use crate::app::context_clues::{BARE_POPUP_CONTEXT_CLUES, ContextClue, ContextClueProvider}; use crate::app::key_binding::DEFAULT_KEYBINDINGS; use crate::models::Route; +use crate::models::servarr_data::lidarr::lidarr_data::{ADD_ARTIST_BLOCKS, ActiveLidarrBlock}; #[cfg(test)] #[path = "lidarr_context_clues_tests.rs"] mod lidarr_context_clues_tests; -pub static ARTISTS_CONTEXT_CLUES: [ContextClue; 9] = [ +pub static ARTISTS_CONTEXT_CLUES: [ContextClue; 10] = [ + (DEFAULT_KEYBINDINGS.add, DEFAULT_KEYBINDINGS.add.desc), ( DEFAULT_KEYBINDINGS.toggle_monitoring, DEFAULT_KEYBINDINGS.toggle_monitoring.desc, @@ -25,6 +27,11 @@ pub static ARTISTS_CONTEXT_CLUES: [ContextClue; 9] = [ (DEFAULT_KEYBINDINGS.esc, "cancel filter"), ]; +pub static ADD_ARTIST_SEARCH_RESULTS_CONTEXT_CLUES: [ContextClue; 2] = [ + (DEFAULT_KEYBINDINGS.submit, "details"), + (DEFAULT_KEYBINDINGS.esc, "edit search"), +]; + pub(in crate::app) struct LidarrContextClueProvider; impl ContextClueProvider for LidarrContextClueProvider { @@ -34,6 +41,12 @@ impl ContextClueProvider for LidarrContextClueProvider { }; match active_lidarr_block { + ActiveLidarrBlock::AddArtistSearchInput | ActiveLidarrBlock::AddArtistEmptySearchResults => { + Some(&BARE_POPUP_CONTEXT_CLUES) + } + _ if ADD_ARTIST_BLOCKS.contains(&active_lidarr_block) => { + Some(&ADD_ARTIST_SEARCH_RESULTS_CONTEXT_CLUES) + } _ => app .data .lidarr_data diff --git a/src/app/lidarr/lidarr_context_clues_tests.rs b/src/app/lidarr/lidarr_context_clues_tests.rs index 5348871..a7cf4ca 100644 --- a/src/app/lidarr/lidarr_context_clues_tests.rs +++ b/src/app/lidarr/lidarr_context_clues_tests.rs @@ -1,18 +1,23 @@ #[cfg(test)] mod tests { use crate::app::App; - use crate::app::context_clues::ContextClueProvider; + use crate::app::context_clues::{BARE_POPUP_CONTEXT_CLUES, ContextClueProvider}; use crate::app::key_binding::DEFAULT_KEYBINDINGS; use crate::app::lidarr::lidarr_context_clues::{ - ARTISTS_CONTEXT_CLUES, LidarrContextClueProvider, + ADD_ARTIST_SEARCH_RESULTS_CONTEXT_CLUES, ARTISTS_CONTEXT_CLUES, LidarrContextClueProvider, }; use crate::models::servarr_data::lidarr::lidarr_data::ActiveLidarrBlock; use crate::models::servarr_data::radarr::radarr_data::ActiveRadarrBlock; + use rstest::rstest; #[test] fn test_artists_context_clues() { let mut artists_context_clues_iter = ARTISTS_CONTEXT_CLUES.iter(); + assert_some_eq_x!( + artists_context_clues_iter.next(), + &(DEFAULT_KEYBINDINGS.add, DEFAULT_KEYBINDINGS.add.desc) + ); assert_some_eq_x!( artists_context_clues_iter.next(), &( @@ -58,6 +63,22 @@ mod tests { assert_none!(artists_context_clues_iter.next()); } + #[test] + fn test_add_artist_search_results_context_clues() { + let mut add_artist_search_results_context_clues_iter = + ADD_ARTIST_SEARCH_RESULTS_CONTEXT_CLUES.iter(); + + assert_some_eq_x!( + add_artist_search_results_context_clues_iter.next(), + &(DEFAULT_KEYBINDINGS.submit, "details") + ); + assert_some_eq_x!( + add_artist_search_results_context_clues_iter.next(), + &(DEFAULT_KEYBINDINGS.esc, "edit search") + ); + assert_none!(add_artist_search_results_context_clues_iter.next()); + } + #[test] #[should_panic( expected = "LidarrContextClueProvider::get_context_clues called with non-Lidarr route" @@ -108,4 +129,30 @@ mod tests { assert_some_eq_x!(context_clues, &ARTISTS_CONTEXT_CLUES); } + + #[rstest] + fn test_lidarr_context_clue_provider_bare_popup_context_clues( + #[values( + ActiveLidarrBlock::AddArtistSearchInput, + ActiveLidarrBlock::AddArtistEmptySearchResults + )] + active_lidarr_block: ActiveLidarrBlock, + ) { + let mut app = App::test_default(); + app.push_navigation_stack(active_lidarr_block.into()); + + let context_clues = LidarrContextClueProvider::get_context_clues(&mut app); + + assert_some_eq_x!(context_clues, &BARE_POPUP_CONTEXT_CLUES); + } + + #[test] + fn test_lidarr_context_clue_provider_add_artist_search_results_context_clues() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveLidarrBlock::AddArtistSearchResults.into()); + + let context_clues = LidarrContextClueProvider::get_context_clues(&mut app); + + assert_some_eq_x!(context_clues, &ADD_ARTIST_SEARCH_RESULTS_CONTEXT_CLUES); + } } diff --git a/src/app/lidarr/lidarr_tests.rs b/src/app/lidarr/lidarr_tests.rs index 872cdd9..9007ac4 100644 --- a/src/app/lidarr/lidarr_tests.rs +++ b/src/app/lidarr/lidarr_tests.rs @@ -4,7 +4,7 @@ mod tests { use crate::models::servarr_data::lidarr::lidarr_data::ActiveLidarrBlock; use crate::network::NetworkEvent; use crate::network::lidarr_network::LidarrEvent; - use pretty_assertions::assert_eq; + use pretty_assertions::{assert_eq, assert_str_eq}; use tokio::sync::mpsc; #[tokio::test] @@ -31,4 +31,33 @@ mod tests { assert!(!app.data.sonarr_data.prompt_confirm); assert_eq!(app.tick_count, 0); } + + #[tokio::test] + async fn test_dispatch_by_lidarr_block_add_artist_search_results() { + let (tx, mut rx) = mpsc::channel::(500); + let mut app = App::test_default(); + app.network_tx = Some(tx); + app.data.lidarr_data.add_artist_search = Some("test artist".into()); + + app + .dispatch_by_lidarr_block(&ActiveLidarrBlock::AddArtistSearchResults) + .await; + + assert!(app.is_loading); + assert_eq!( + rx.recv().await.unwrap(), + LidarrEvent::SearchNewArtist("test artist".to_owned()).into() + ); + assert!(!app.data.lidarr_data.prompt_confirm); + assert_eq!(app.tick_count, 0); + } + + #[tokio::test] + async fn test_extract_add_new_artist_search_query() { + let app = App::test_default_fully_populated(); + + let query = app.extract_add_new_artist_search_query().await; + + assert_str_eq!(query, "Test Artist"); + } } diff --git a/src/app/lidarr/mod.rs b/src/app/lidarr/mod.rs index 716376f..7c5b118 100644 --- a/src/app/lidarr/mod.rs +++ b/src/app/lidarr/mod.rs @@ -28,6 +28,13 @@ impl App<'_> { .dispatch_network_event(LidarrEvent::ListArtists.into()) .await; } + ActiveLidarrBlock::AddArtistSearchResults => { + self + .dispatch_network_event( + LidarrEvent::SearchNewArtist(self.extract_add_new_artist_search_query().await).into(), + ) + .await; + } _ => (), } @@ -35,6 +42,17 @@ impl App<'_> { self.reset_tick_count(); } + async fn extract_add_new_artist_search_query(&self) -> String { + self + .data + .lidarr_data + .add_artist_search + .as_ref() + .expect("add_artist_search should be set") + .text + .clone() + } + async fn check_for_lidarr_prompt_action(&mut self) { if self.data.lidarr_data.prompt_confirm { self.data.lidarr_data.prompt_confirm = false; diff --git a/src/cli/lidarr/lidarr_command_tests.rs b/src/cli/lidarr/lidarr_command_tests.rs index 4dbafb1..59c37d9 100644 --- a/src/cli/lidarr/lidarr_command_tests.rs +++ b/src/cli/lidarr/lidarr_command_tests.rs @@ -74,6 +74,30 @@ mod tests { assert_ok!(&result); } + + #[test] + fn test_search_new_artist_requires_query() { + let result = Cli::command().try_get_matches_from(["managarr", "lidarr", "search-new-artist"]); + + assert_err!(&result); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_search_new_artist_requirements_satisfied() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "lidarr", + "search-new-artist", + "--query", + "test query", + ]); + + assert_ok!(&result); + } } mod handler { @@ -255,5 +279,32 @@ mod tests { assert_ok!(&result); } + + #[tokio::test] + async fn test_search_new_artist_command() { + let expected_query = "test artist".to_owned(); + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::( + LidarrEvent::SearchNewArtist(expected_query.clone()).into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Lidarr(LidarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::test_default())); + let search_new_artist_command = LidarrCommand::SearchNewArtist { + query: expected_query, + }; + + let result = LidarrCliHandler::with(&app_arc, search_new_artist_command, &mut mock_network) + .handle() + .await; + + assert_ok!(&result); + } } } diff --git a/src/cli/lidarr/mod.rs b/src/cli/lidarr/mod.rs index 306051b..29bc93f 100644 --- a/src/cli/lidarr/mod.rs +++ b/src/cli/lidarr/mod.rs @@ -58,6 +58,15 @@ pub enum LidarrCommand { about = "Commands to refresh the data in your Lidarr instance" )] Refresh(LidarrRefreshCommand), + #[command(about = "Search for a new artist to add to Lidarr")] + SearchNewArtist { + #[arg( + long, + help = "The name of the artist you want to search for", + required = true + )] + query: String, + }, #[command( about = "Toggle monitoring for the specified artist corresponding to the given artist ID" )] @@ -128,6 +137,13 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, LidarrCommand> for LidarrCliHandler<'a, ' .handle() .await? } + LidarrCommand::SearchNewArtist { query } => { + let resp = self + .network + .handle_network_event(LidarrEvent::SearchNewArtist(query).into()) + .await?; + serde_json::to_string_pretty(&resp)? + } LidarrCommand::ToggleArtistMonitoring { artist_id } => { let resp = self .network diff --git a/src/handlers/lidarr_handlers/library/add_artist_handler.rs b/src/handlers/lidarr_handlers/library/add_artist_handler.rs new file mode 100644 index 0000000..39b8fae --- /dev/null +++ b/src/handlers/lidarr_handlers/library/add_artist_handler.rs @@ -0,0 +1,181 @@ +use crate::handlers::KeyEventHandler; +use crate::handlers::table_handler::{TableHandlingConfig, handle_table}; +use crate::models::Route; +use crate::models::servarr_data::lidarr::lidarr_data::{ADD_ARTIST_BLOCKS, ActiveLidarrBlock}; +use crate::{App, Key, handle_text_box_keys, handle_text_box_left_right_keys}; + +#[cfg(test)] +#[path = "add_artist_handler_tests.rs"] +mod add_artist_handler_tests; + +pub struct AddArtistHandler<'a, 'b> { + key: Key, + app: &'a mut App<'b>, + active_lidarr_block: ActiveLidarrBlock, + _context: Option, +} + +impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveLidarrBlock> for AddArtistHandler<'a, 'b> { + fn handle(&mut self) { + let add_artist_table_handling_config = + TableHandlingConfig::new(ActiveLidarrBlock::AddArtistSearchResults.into()); + + if !handle_table( + self, + |app| { + app + .data + .lidarr_data + .add_searched_artists + .as_mut() + .expect("add_searched_artists should be initialized") + }, + add_artist_table_handling_config, + ) { + self.handle_key_event(); + } + } + + fn accepts(active_block: ActiveLidarrBlock) -> bool { + ADD_ARTIST_BLOCKS.contains(&active_block) + } + + fn ignore_special_keys(&self) -> bool { + self.app.ignore_special_keys_for_textbox_input + } + + fn new( + key: Key, + app: &'a mut App<'b>, + active_block: ActiveLidarrBlock, + context: Option, + ) -> AddArtistHandler<'a, 'b> { + AddArtistHandler { + key, + app, + active_lidarr_block: active_block, + _context: context, + } + } + + fn get_key(&self) -> Key { + self.key + } + + fn is_ready(&self) -> bool { + !self.app.is_loading + } + + fn handle_scroll_up(&mut self) {} + + fn handle_scroll_down(&mut self) {} + + fn handle_home(&mut self) { + if self.active_lidarr_block == ActiveLidarrBlock::AddArtistSearchInput { + self + .app + .data + .lidarr_data + .add_artist_search + .as_mut() + .unwrap() + .scroll_home(); + } + } + + fn handle_end(&mut self) { + if self.active_lidarr_block == ActiveLidarrBlock::AddArtistSearchInput { + self + .app + .data + .lidarr_data + .add_artist_search + .as_mut() + .unwrap() + .reset_offset(); + } + } + + fn handle_delete(&mut self) {} + + fn handle_left_right_action(&mut self) { + if self.active_lidarr_block == ActiveLidarrBlock::AddArtistSearchInput { + handle_text_box_left_right_keys!( + self, + self.key, + self + .app + .data + .lidarr_data + .add_artist_search + .as_mut() + .unwrap() + ) + } + } + + fn handle_submit(&mut self) { + match self.active_lidarr_block { + ActiveLidarrBlock::AddArtistSearchInput => { + let search_text = &self + .app + .data + .lidarr_data + .add_artist_search + .as_ref() + .unwrap() + .text; + + if !search_text.is_empty() { + self + .app + .push_navigation_stack(ActiveLidarrBlock::AddArtistSearchResults.into()); + self.app.ignore_special_keys_for_textbox_input = false; + } + } + ActiveLidarrBlock::AddArtistSearchResults => {} + _ => (), + } + } + + fn handle_esc(&mut self) { + match self.active_lidarr_block { + ActiveLidarrBlock::AddArtistSearchInput => { + self.app.pop_navigation_stack(); + self.app.data.lidarr_data.add_artist_search = None; + self.app.ignore_special_keys_for_textbox_input = false; + } + ActiveLidarrBlock::AddArtistSearchResults + | ActiveLidarrBlock::AddArtistEmptySearchResults => { + self.app.pop_navigation_stack(); + self.app.data.lidarr_data.add_searched_artists = None; + self.app.ignore_special_keys_for_textbox_input = true; + } + _ => (), + } + } + + fn handle_char_key_event(&mut self) { + if self.active_lidarr_block == ActiveLidarrBlock::AddArtistSearchInput { + handle_text_box_keys!( + self, + self.key, + self + .app + .data + .lidarr_data + .add_artist_search + .as_mut() + .unwrap() + ) + } + } + + fn app_mut(&mut self) -> &mut App<'b> { + self.app + } + + fn current_route(&self) -> Route { + self.app.get_current_route() + } +} diff --git a/src/handlers/lidarr_handlers/library/add_artist_handler_tests.rs b/src/handlers/lidarr_handlers/library/add_artist_handler_tests.rs new file mode 100644 index 0000000..dba8ca9 --- /dev/null +++ b/src/handlers/lidarr_handlers/library/add_artist_handler_tests.rs @@ -0,0 +1,356 @@ +#[cfg(test)] +mod tests { + use pretty_assertions::assert_str_eq; + use rstest::rstest; + use std::sync::atomic::Ordering; + use strum::IntoEnumIterator; + + use crate::app::App; + use crate::app::key_binding::DEFAULT_KEYBINDINGS; + use crate::assert_navigation_popped; + use crate::event::Key; + use crate::handlers::KeyEventHandler; + use crate::handlers::lidarr_handlers::library::add_artist_handler::AddArtistHandler; + use crate::models::HorizontallyScrollableText; + use crate::models::servarr_data::lidarr::lidarr_data::{ADD_ARTIST_BLOCKS, ActiveLidarrBlock}; + use crate::models::stateful_table::StatefulTable; + use crate::network::lidarr_network::lidarr_network_test_utils::test_utils::add_artist_search_result; + + mod test_handle_home_end { + use super::*; + use pretty_assertions::assert_eq; + + #[test] + fn test_add_artist_search_input_home_end_keys() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveLidarrBlock::AddArtistSearchInput.into()); + app.data.lidarr_data.add_artist_search = Some("Test".into()); + + AddArtistHandler::new( + DEFAULT_KEYBINDINGS.home.key, + &mut app, + ActiveLidarrBlock::AddArtistSearchInput, + None, + ) + .handle(); + + assert_eq!( + app + .data + .lidarr_data + .add_artist_search + .as_ref() + .unwrap() + .offset + .load(Ordering::SeqCst), + 4 + ); + + AddArtistHandler::new( + DEFAULT_KEYBINDINGS.end.key, + &mut app, + ActiveLidarrBlock::AddArtistSearchInput, + None, + ) + .handle(); + + assert_eq!( + app + .data + .lidarr_data + .add_artist_search + .as_ref() + .unwrap() + .offset + .load(Ordering::SeqCst), + 0 + ); + } + } + + mod test_handle_left_right_action { + use super::*; + use pretty_assertions::assert_eq; + + #[test] + fn test_add_artist_search_input_left_right_keys() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveLidarrBlock::AddArtistSearchInput.into()); + app.data.lidarr_data.add_artist_search = Some("Test".into()); + + AddArtistHandler::new( + DEFAULT_KEYBINDINGS.left.key, + &mut app, + ActiveLidarrBlock::AddArtistSearchInput, + None, + ) + .handle(); + + assert_eq!( + app + .data + .lidarr_data + .add_artist_search + .as_ref() + .unwrap() + .offset + .load(Ordering::SeqCst), + 1 + ); + + AddArtistHandler::new( + DEFAULT_KEYBINDINGS.right.key, + &mut app, + ActiveLidarrBlock::AddArtistSearchInput, + None, + ) + .handle(); + + assert_eq!( + app + .data + .lidarr_data + .add_artist_search + .as_ref() + .unwrap() + .offset + .load(Ordering::SeqCst), + 0 + ); + } + } + + mod test_handle_submit { + use super::*; + use pretty_assertions::assert_eq; + + const SUBMIT_KEY: Key = DEFAULT_KEYBINDINGS.submit.key; + + #[test] + fn test_add_artist_search_input_submit() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveLidarrBlock::AddArtistSearchInput.into()); + app.ignore_special_keys_for_textbox_input = true; + app.data.lidarr_data.add_artist_search = Some("test".into()); + + AddArtistHandler::new( + SUBMIT_KEY, + &mut app, + ActiveLidarrBlock::AddArtistSearchInput, + None, + ) + .handle(); + + assert!(!app.ignore_special_keys_for_textbox_input); + assert_eq!( + app.get_current_route(), + ActiveLidarrBlock::AddArtistSearchResults.into() + ); + } + + #[test] + fn test_add_artist_search_input_submit_noop_on_empty_search() { + let mut app = App::test_default(); + app.data.lidarr_data.add_artist_search = Some(HorizontallyScrollableText::default()); + app.push_navigation_stack(ActiveLidarrBlock::Artists.into()); + app.push_navigation_stack(ActiveLidarrBlock::AddArtistSearchInput.into()); + app.ignore_special_keys_for_textbox_input = true; + + AddArtistHandler::new( + SUBMIT_KEY, + &mut app, + ActiveLidarrBlock::AddArtistSearchInput, + None, + ) + .handle(); + + assert!(app.ignore_special_keys_for_textbox_input); + assert_eq!( + app.get_current_route(), + ActiveLidarrBlock::AddArtistSearchInput.into() + ); + } + } + + mod test_handle_esc { + use super::*; + use crate::assert_modal_absent; + use rstest::rstest; + + const ESC_KEY: Key = DEFAULT_KEYBINDINGS.esc.key; + + #[rstest] + fn test_add_artist_search_input_esc(#[values(true, false)] is_ready: bool) { + let mut app = App::test_default(); + app.is_loading = is_ready; + app.data.lidarr_data.add_artist_search = Some("test".into()); + app.ignore_special_keys_for_textbox_input = true; + app.push_navigation_stack(ActiveLidarrBlock::Artists.into()); + app.push_navigation_stack(ActiveLidarrBlock::AddArtistSearchInput.into()); + + AddArtistHandler::new( + ESC_KEY, + &mut app, + ActiveLidarrBlock::AddArtistSearchInput, + None, + ) + .handle(); + + assert!(!app.ignore_special_keys_for_textbox_input); + assert_navigation_popped!(app, ActiveLidarrBlock::Artists.into()); + assert_modal_absent!(app.data.lidarr_data.add_artist_search); + } + + #[rstest] + fn test_add_artist_search_results_esc( + #[values( + ActiveLidarrBlock::AddArtistSearchResults, + ActiveLidarrBlock::AddArtistEmptySearchResults + )] + active_lidarr_block: ActiveLidarrBlock, + ) { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveLidarrBlock::AddArtistSearchInput.into()); + app.push_navigation_stack(active_lidarr_block.into()); + let mut add_searched_artists = StatefulTable::default(); + add_searched_artists.set_items(vec![add_artist_search_result()]); + app.data.lidarr_data.add_searched_artists = Some(add_searched_artists); + + AddArtistHandler::new(ESC_KEY, &mut app, active_lidarr_block, None).handle(); + + assert_navigation_popped!(app, ActiveLidarrBlock::AddArtistSearchInput.into()); + assert_modal_absent!(app.data.lidarr_data.add_searched_artists); + assert!(app.ignore_special_keys_for_textbox_input); + } + } + + mod test_handle_key_char { + use super::*; + + #[test] + fn test_add_artist_search_input_backspace() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveLidarrBlock::Artists.into()); + app.data.lidarr_data.add_artist_search = Some("Test".into()); + + AddArtistHandler::new( + DEFAULT_KEYBINDINGS.backspace.key, + &mut app, + ActiveLidarrBlock::AddArtistSearchInput, + None, + ) + .handle(); + + assert_str_eq!( + app + .data + .lidarr_data + .add_artist_search + .as_ref() + .unwrap() + .text, + "Tes" + ); + } + + #[test] + fn test_add_artist_search_input_char_key() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveLidarrBlock::Artists.into()); + app.data.lidarr_data.add_artist_search = Some(HorizontallyScrollableText::default()); + + AddArtistHandler::new( + Key::Char('a'), + &mut app, + ActiveLidarrBlock::AddArtistSearchInput, + None, + ) + .handle(); + + assert_str_eq!( + app + .data + .lidarr_data + .add_artist_search + .as_ref() + .unwrap() + .text, + "a" + ); + } + } + + #[test] + fn test_add_artist_handler_accepts() { + ActiveLidarrBlock::iter().for_each(|active_lidarr_block| { + if ADD_ARTIST_BLOCKS.contains(&active_lidarr_block) { + assert!(AddArtistHandler::accepts(active_lidarr_block)); + } else { + assert!(!AddArtistHandler::accepts(active_lidarr_block)); + } + }); + } + + #[rstest] + fn test_add_artist_handler_ignore_special_keys( + #[values(true, false)] ignore_special_keys_for_textbox_input: bool, + ) { + let mut app = App::test_default(); + app.ignore_special_keys_for_textbox_input = ignore_special_keys_for_textbox_input; + let handler = AddArtistHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveLidarrBlock::default(), + None, + ); + + assert_eq!( + handler.ignore_special_keys(), + ignore_special_keys_for_textbox_input + ); + } + + #[test] + fn test_add_artist_search_no_panic_on_none_search_result() { + let mut app = App::test_default(); + app.data.lidarr_data.add_searched_artists = None; + + AddArtistHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveLidarrBlock::AddArtistSearchResults, + None, + ) + .handle(); + } + + #[test] + fn test_add_artist_handler_is_not_ready_when_loading() { + let mut app = App::test_default(); + app.is_loading = true; + + let handler = AddArtistHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveLidarrBlock::AddArtistSearchInput, + None, + ); + + assert!(!handler.is_ready()); + } + + #[test] + fn test_add_artist_handler_is_ready_when_not_loading() { + let mut app = App::test_default(); + app.is_loading = false; + + let handler = AddArtistHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveLidarrBlock::AddArtistSearchInput, + None, + ); + + assert!(handler.is_ready()); + } +} diff --git a/src/handlers/lidarr_handlers/library/library_handler_tests.rs b/src/handlers/lidarr_handlers/library/library_handler_tests.rs index 7678f34..485ee90 100644 --- a/src/handlers/lidarr_handlers/library/library_handler_tests.rs +++ b/src/handlers/lidarr_handlers/library/library_handler_tests.rs @@ -13,8 +13,8 @@ mod tests { use crate::handlers::lidarr_handlers::library::{LibraryHandler, artists_sorting_options}; use crate::models::lidarr_models::{Artist, ArtistStatistics, ArtistStatus}; use crate::models::servarr_data::lidarr::lidarr_data::{ - ActiveLidarrBlock, DELETE_ARTIST_BLOCKS, EDIT_ARTIST_BLOCKS, EDIT_ARTIST_SELECTION_BLOCKS, - LIBRARY_BLOCKS, + ADD_ARTIST_BLOCKS, ActiveLidarrBlock, DELETE_ARTIST_BLOCKS, EDIT_ARTIST_BLOCKS, + EDIT_ARTIST_SELECTION_BLOCKS, LIBRARY_BLOCKS, }; use crate::models::servarr_data::lidarr::modals::EditArtistModal; use crate::network::lidarr_network::LidarrEvent; @@ -28,6 +28,7 @@ mod tests { library_handler_blocks.extend(LIBRARY_BLOCKS); library_handler_blocks.extend(DELETE_ARTIST_BLOCKS); library_handler_blocks.extend(EDIT_ARTIST_BLOCKS); + library_handler_blocks.extend(ADD_ARTIST_BLOCKS); ActiveLidarrBlock::iter().for_each(|lidarr_block| { if library_handler_blocks.contains(&lidarr_block) { @@ -502,6 +503,35 @@ mod tests { ] } + #[rstest] + fn test_delegates_add_artist_blocks_to_add_artist_handler( + #[values( + ActiveLidarrBlock::AddArtistSearchInput, + ActiveLidarrBlock::AddArtistEmptySearchResults, + ActiveLidarrBlock::AddArtistSearchResults + )] + active_lidarr_block: ActiveLidarrBlock, + ) { + let mut app = App::test_default_fully_populated(); + app + .data + .lidarr_data + .artists + .set_items(vec![Artist::default()]); + app.push_navigation_stack(ActiveLidarrBlock::Artists.into()); + app.push_navigation_stack(active_lidarr_block.into()); + + LibraryHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + active_lidarr_block, + None, + ) + .handle(); + + assert_eq!(app.get_current_route(), ActiveLidarrBlock::Artists.into()); + } + #[test] fn test_delegates_delete_artist_blocks_to_delete_artist_handler() { let mut app = App::test_default(); diff --git a/src/handlers/lidarr_handlers/library/mod.rs b/src/handlers/lidarr_handlers/library/mod.rs index fdbf9c2..9bee4f3 100644 --- a/src/handlers/lidarr_handlers/library/mod.rs +++ b/src/handlers/lidarr_handlers/library/mod.rs @@ -4,7 +4,7 @@ use crate::{ handlers::{KeyEventHandler, handle_clear_errors, handle_prompt_toggle}, matches_key, models::{ - BlockSelectionState, + BlockSelectionState, HorizontallyScrollableText, lidarr_models::Artist, servarr_data::lidarr::lidarr_data::{ ActiveLidarrBlock, DELETE_ARTIST_SELECTION_BLOCKS, EDIT_ARTIST_SELECTION_BLOCKS, @@ -18,10 +18,12 @@ use crate::{ use super::handle_change_tab_left_right_keys; use crate::handlers::table_handler::{TableHandlingConfig, handle_table}; +mod add_artist_handler; mod delete_artist_handler; mod edit_artist_handler; use crate::models::Route; +pub(in crate::handlers::lidarr_handlers) use add_artist_handler::AddArtistHandler; pub(in crate::handlers::lidarr_handlers) use delete_artist_handler::DeleteArtistHandler; pub(in crate::handlers::lidarr_handlers) use edit_artist_handler::EditArtistHandler; @@ -60,6 +62,10 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveLidarrBlock> for LibraryHandler<'a, ' artists_table_handling_config, ) { match self.active_lidarr_block { + _ if AddArtistHandler::accepts(self.active_lidarr_block) => { + AddArtistHandler::new(self.key, self.app, self.active_lidarr_block, self.context) + .handle(); + } _ if DeleteArtistHandler::accepts(self.active_lidarr_block) => { DeleteArtistHandler::new(self.key, self.app, self.active_lidarr_block, self.context) .handle(); @@ -74,7 +80,8 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveLidarrBlock> for LibraryHandler<'a, ' } fn accepts(active_block: ActiveLidarrBlock) -> bool { - DeleteArtistHandler::accepts(active_block) + AddArtistHandler::accepts(active_block) + || DeleteArtistHandler::accepts(active_block) || EditArtistHandler::accepts(active_block) || LIBRARY_BLOCKS.contains(&active_block) } @@ -157,6 +164,13 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveLidarrBlock> for LibraryHandler<'a, ' let key = self.key; match self.active_lidarr_block { ActiveLidarrBlock::Artists => match key { + _ if matches_key!(add, key) => { + self + .app + .push_navigation_stack(ActiveLidarrBlock::AddArtistSearchInput.into()); + self.app.data.lidarr_data.add_artist_search = Some(HorizontallyScrollableText::default()); + self.app.ignore_special_keys_for_textbox_input = true; + } _ if matches_key!(toggle_monitoring, key) => { self.app.data.lidarr_data.prompt_confirm = true; self.app.data.lidarr_data.prompt_confirm_action = Some( diff --git a/src/models/lidarr_models.rs b/src/models/lidarr_models.rs index 56278b0..21046ab 100644 --- a/src/models/lidarr_models.rs +++ b/src/models/lidarr_models.rs @@ -198,6 +198,19 @@ pub struct SystemStatus { pub start_time: DateTime, } +#[derive(Derivative, Serialize, Deserialize, Debug, Default, Clone, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct AddArtistSearchResult { + pub foreign_artist_id: String, + pub artist_name: HorizontallyScrollableText, + pub status: ArtistStatus, + pub overview: Option, + pub artist_type: Option, + pub disambiguation: Option, + pub genres: Vec, + pub ratings: Option, +} + #[derive(Serialize, Deserialize, Debug, Default, Clone, Eq, PartialEq)] #[serde(rename_all = "lowercase")] pub struct DeleteArtistParams { @@ -229,6 +242,7 @@ impl From for Serdeable { serde_enum_from!( LidarrSerdeable { + AddArtistSearchResults(Vec), Artist(Artist), Artists(Vec), DiskSpaces(Vec), diff --git a/src/models/lidarr_models_tests.rs b/src/models/lidarr_models_tests.rs index bb3f478..579f817 100644 --- a/src/models/lidarr_models_tests.rs +++ b/src/models/lidarr_models_tests.rs @@ -5,8 +5,8 @@ mod tests { use serde_json::json; use crate::models::lidarr_models::{ - DownloadRecord, DownloadStatus, DownloadsResponse, Member, MetadataProfile, NewItemMonitorType, - SystemStatus, + AddArtistSearchResult, DownloadRecord, DownloadStatus, DownloadsResponse, Member, + MetadataProfile, NewItemMonitorType, SystemStatus, }; use crate::models::servarr_models::{ DiskSpace, HostConfig, QualityProfile, RootFolder, SecurityConfig, Tag, @@ -424,4 +424,72 @@ mod tests { ); assert_str_eq!(DownloadStatus::Fallback.to_display_str(), "Fallback"); } + + #[test] + fn test_add_artist_search_result_deserialization() { + let search_result_json = json!({ + "foreignArtistId": "test-foreign-id", + "artistName": "Test Artist", + "status": "continuing", + "overview": "Test overview", + "artistType": "Group", + "disambiguation": "UK Band", + "genres": ["Rock", "Alternative"], + "ratings": { + "votes": 100, + "value": 4.5 + } + }); + + let search_result: AddArtistSearchResult = serde_json::from_value(search_result_json).unwrap(); + + assert_str_eq!(search_result.foreign_artist_id, "test-foreign-id"); + assert_str_eq!(search_result.artist_name.text, "Test Artist"); + assert_eq!(search_result.status, ArtistStatus::Continuing); + assert_some_eq_x!(&search_result.overview, "Test overview"); + assert_some_eq_x!(&search_result.artist_type, "Group"); + assert_some_eq_x!(&search_result.disambiguation, "UK Band"); + assert_eq!(search_result.genres, vec!["Rock", "Alternative"]); + assert_some!(&search_result.ratings); + + let ratings = search_result.ratings.unwrap(); + assert_eq!(ratings.votes, 100); + assert_eq!(ratings.value, 4.5); + } + + #[test] + fn test_add_artist_search_result_with_optional_fields_none() { + let search_result_json = json!({ + "foreignArtistId": "test-foreign-id", + "artistName": "Test Artist", + "status": "ended", + "genres": [] + }); + + let search_result: AddArtistSearchResult = serde_json::from_value(search_result_json).unwrap(); + + assert_str_eq!(search_result.foreign_artist_id, "test-foreign-id"); + assert_str_eq!(search_result.artist_name.text, "Test Artist"); + assert_eq!(search_result.status, ArtistStatus::Ended); + assert_none!(&search_result.overview); + assert_none!(&search_result.artist_type); + assert_none!(&search_result.disambiguation); + assert!(search_result.genres.is_empty()); + assert_none!(&search_result.ratings); + } + + #[test] + fn test_lidarr_serdeable_from_add_artist_search_results() { + let search_results = vec![AddArtistSearchResult { + foreign_artist_id: "test-id".to_owned(), + ..AddArtistSearchResult::default() + }]; + + let lidarr_serdeable: LidarrSerdeable = search_results.clone().into(); + + assert_eq!( + lidarr_serdeable, + LidarrSerdeable::AddArtistSearchResults(search_results) + ); + } } diff --git a/src/models/servarr_data/lidarr/lidarr_data.rs b/src/models/servarr_data/lidarr/lidarr_data.rs index 240f68d..14b4753 100644 --- a/src/models/servarr_data/lidarr/lidarr_data.rs +++ b/src/models/servarr_data/lidarr/lidarr_data.rs @@ -3,8 +3,8 @@ use serde_json::Number; use super::modals::EditArtistModal; use crate::app::lidarr::lidarr_context_clues::ARTISTS_CONTEXT_CLUES; use crate::models::{ - BlockSelectionState, Route, TabRoute, TabState, - lidarr_models::{Artist, DownloadRecord}, + BlockSelectionState, HorizontallyScrollableText, Route, TabRoute, TabState, + lidarr_models::{AddArtistSearchResult, Artist, DownloadRecord}, servarr_models::{DiskSpace, RootFolder}, stateful_table::StatefulTable, }; @@ -18,7 +18,8 @@ use { crate::models::stateful_table::SortOption, crate::network::lidarr_network::lidarr_network_test_utils::test_utils::quality_profile_map, crate::network::lidarr_network::lidarr_network_test_utils::test_utils::{ - download_record, metadata_profile, metadata_profile_map, quality_profile, root_folder, tags_map, + add_artist_search_result, download_record, metadata_profile, metadata_profile_map, + quality_profile, root_folder, tags_map, }, crate::network::servarr_test_utils::diskspace, strum::{Display, EnumString, IntoEnumIterator}, @@ -29,7 +30,9 @@ use { mod lidarr_data_tests; pub struct LidarrData<'a> { + pub add_artist_search: Option, pub add_import_list_exclusion: bool, + pub add_searched_artists: Option>, pub artists: StatefulTable, pub delete_artist_files: bool, pub disk_space_vec: Vec, @@ -82,7 +85,9 @@ impl LidarrData<'_> { impl<'a> Default for LidarrData<'a> { fn default() -> LidarrData<'a> { LidarrData { + add_artist_search: None, add_import_list_exclusion: false, + add_searched_artists: None, artists: StatefulTable::default(), delete_artist_files: false, disk_space_vec: Vec::new(), @@ -145,6 +150,10 @@ impl LidarrData<'_> { lidarr_data.downloads.set_items(vec![download_record()]); lidarr_data.root_folders.set_items(vec![root_folder()]); lidarr_data.version = "1.0.0".to_owned(); + lidarr_data.add_artist_search = Some("Test Artist".into()); + let mut add_searched_artists = StatefulTable::default(); + add_searched_artists.set_items(vec![add_artist_search_result()]); + lidarr_data.add_searched_artists = Some(add_searched_artists); lidarr_data } @@ -156,6 +165,9 @@ pub enum ActiveLidarrBlock { #[default] Artists, ArtistsSortPrompt, + AddArtistEmptySearchResults, + AddArtistSearchInput, + AddArtistSearchResults, DeleteArtistPrompt, DeleteArtistConfirmPrompt, DeleteArtistToggleDeleteFile, @@ -185,6 +197,12 @@ pub static LIBRARY_BLOCKS: [ActiveLidarrBlock; 7] = [ ActiveLidarrBlock::UpdateAllArtistsPrompt, ]; +pub static ADD_ARTIST_BLOCKS: [ActiveLidarrBlock; 3] = [ + ActiveLidarrBlock::AddArtistEmptySearchResults, + ActiveLidarrBlock::AddArtistSearchInput, + ActiveLidarrBlock::AddArtistSearchResults, +]; + pub static DELETE_ARTIST_BLOCKS: [ActiveLidarrBlock; 4] = [ ActiveLidarrBlock::DeleteArtistPrompt, ActiveLidarrBlock::DeleteArtistConfirmPrompt, diff --git a/src/models/servarr_data/lidarr/lidarr_data_tests.rs b/src/models/servarr_data/lidarr/lidarr_data_tests.rs index ceb3417..41e5c8c 100644 --- a/src/models/servarr_data/lidarr/lidarr_data_tests.rs +++ b/src/models/servarr_data/lidarr/lidarr_data_tests.rs @@ -2,7 +2,7 @@ mod tests { use crate::app::lidarr::lidarr_context_clues::ARTISTS_CONTEXT_CLUES; use crate::models::servarr_data::lidarr::lidarr_data::{ - DELETE_ARTIST_BLOCKS, DELETE_ARTIST_SELECTION_BLOCKS, EDIT_ARTIST_BLOCKS, + ADD_ARTIST_BLOCKS, DELETE_ARTIST_BLOCKS, DELETE_ARTIST_SELECTION_BLOCKS, EDIT_ARTIST_BLOCKS, EDIT_ARTIST_SELECTION_BLOCKS, }; use crate::models::{ @@ -109,7 +109,9 @@ mod tests { fn test_lidarr_data_default() { let lidarr_data = LidarrData::default(); + assert_none!(lidarr_data.add_artist_search); assert!(!lidarr_data.add_import_list_exclusion); + assert_none!(lidarr_data.add_searched_artists); assert_is_empty!(lidarr_data.artists); assert!(!lidarr_data.delete_artist_files); assert_is_empty!(lidarr_data.disk_space_vec); @@ -151,6 +153,14 @@ mod tests { assert!(LIBRARY_BLOCKS.contains(&ActiveLidarrBlock::UpdateAllArtistsPrompt)); } + #[test] + fn test_add_artist_blocks_contents() { + assert_eq!(ADD_ARTIST_BLOCKS.len(), 3); + assert!(ADD_ARTIST_BLOCKS.contains(&ActiveLidarrBlock::AddArtistEmptySearchResults)); + assert!(ADD_ARTIST_BLOCKS.contains(&ActiveLidarrBlock::AddArtistSearchInput)); + assert!(ADD_ARTIST_BLOCKS.contains(&ActiveLidarrBlock::AddArtistSearchResults)); + } + #[test] fn test_delete_artist_blocks_contents() { assert_eq!(DELETE_ARTIST_BLOCKS.len(), 4); diff --git a/src/network/lidarr_network/library/lidarr_library_network_tests.rs b/src/network/lidarr_network/library/lidarr_library_network_tests.rs index b0045f1..ec64c38 100644 --- a/src/network/lidarr_network/library/lidarr_library_network_tests.rs +++ b/src/network/lidarr_network/library/lidarr_library_network_tests.rs @@ -1,11 +1,15 @@ #[cfg(test)] mod tests { use crate::models::lidarr_models::{ - Artist, DeleteArtistParams, EditArtistParams, LidarrSerdeable, NewItemMonitorType, + AddArtistSearchResult, Artist, DeleteArtistParams, EditArtistParams, LidarrSerdeable, + NewItemMonitorType, }; + use crate::models::servarr_data::lidarr::lidarr_data::ActiveLidarrBlock; use crate::network::NetworkResource; use crate::network::lidarr_network::LidarrEvent; - use crate::network::lidarr_network::lidarr_network_test_utils::test_utils::ARTIST_JSON; + use crate::network::lidarr_network::lidarr_network_test_utils::test_utils::{ + ADD_ARTIST_SEARCH_RESULT_JSON, ARTIST_JSON, + }; use crate::network::network_tests::test_utils::{MockServarrApi, test_network}; use bimap::BiMap; use mockito::Matcher; @@ -356,4 +360,83 @@ mod tests { async_details_server.assert_async().await; async_edit_server.assert_async().await; } + + #[tokio::test] + async fn test_handle_search_new_artist_event() { + let search_results_json = + json!([serde_json::from_str::(ADD_ARTIST_SEARCH_RESULT_JSON).unwrap()]); + let expected_results: Vec = + serde_json::from_value(search_results_json.clone()).unwrap(); + let (mock, app, _server) = MockServarrApi::get() + .returns(search_results_json) + .query("term=test%20artist") + .build_for(LidarrEvent::SearchNewArtist("test artist".to_owned())) + .await; + app.lock().await.server_tabs.set_index(2); + let mut network = test_network(&app); + + let result = network + .handle_lidarr_event(LidarrEvent::SearchNewArtist("test artist".to_owned())) + .await; + + mock.assert_async().await; + + let LidarrSerdeable::AddArtistSearchResults(search_results) = result.unwrap() else { + panic!("Expected AddArtistSearchResults"); + }; + + assert_eq!(search_results, expected_results); + assert_some!(&app.lock().await.data.lidarr_data.add_searched_artists); + } + + #[tokio::test] + async fn test_handle_search_new_artist_event_navigates_to_empty_results_when_empty() { + let (mock, app, _server) = MockServarrApi::get() + .returns(json!([])) + .query("term=nonexistent") + .build_for(LidarrEvent::SearchNewArtist("nonexistent".to_owned())) + .await; + app.lock().await.server_tabs.set_index(2); + app + .lock() + .await + .push_navigation_stack(ActiveLidarrBlock::AddArtistSearchResults.into()); + let mut network = test_network(&app); + + let result = network + .handle_lidarr_event(LidarrEvent::SearchNewArtist("nonexistent".to_owned())) + .await; + + mock.assert_async().await; + + assert_ok!(result); + let app = app.lock().await; + assert_none!(&app.data.lidarr_data.add_searched_artists); + assert_eq!( + app.get_current_route(), + ActiveLidarrBlock::AddArtistEmptySearchResults.into() + ); + } + + #[tokio::test] + async fn test_handle_search_new_artist_event_sets_empty_table_on_api_error() { + let (mock, app, _server) = MockServarrApi::get() + .status(500) + .query("term=nonexistent") + .build_for(LidarrEvent::SearchNewArtist("nonexistent".to_owned())) + .await; + app.lock().await.server_tabs.set_index(2); + let mut network = test_network(&app); + + let result = network + .handle_lidarr_event(LidarrEvent::SearchNewArtist("nonexistent".to_owned())) + .await; + + mock.assert_async().await; + + assert_err!(result); + let app = app.lock().await; + assert_some!(&app.data.lidarr_data.add_searched_artists); + assert_is_empty!(app.data.lidarr_data.add_searched_artists.as_ref().unwrap()); + } } diff --git a/src/network/lidarr_network/library/mod.rs b/src/network/lidarr_network/library/mod.rs index a9264e0..0016d08 100644 --- a/src/network/lidarr_network/library/mod.rs +++ b/src/network/lidarr_network/library/mod.rs @@ -3,11 +3,15 @@ use log::{debug, info, warn}; use serde_json::{Value, json}; use crate::models::Route; -use crate::models::lidarr_models::{Artist, DeleteArtistParams, EditArtistParams}; +use crate::models::lidarr_models::{ + AddArtistSearchResult, Artist, DeleteArtistParams, EditArtistParams, +}; use crate::models::servarr_data::lidarr::lidarr_data::ActiveLidarrBlock; use crate::models::servarr_models::CommandBody; +use crate::models::stateful_table::StatefulTable; use crate::network::lidarr_network::LidarrEvent; use crate::network::{Network, RequestMethod}; +use urlencoding::encode; #[cfg(test)] #[path = "lidarr_library_network_tests.rs"] @@ -169,6 +173,46 @@ impl Network<'_, '_> { .await } + pub(in crate::network::lidarr_network) async fn search_artist( + &mut self, + query: String, + ) -> Result> { + info!("Searching for artist: {query}"); + let event = LidarrEvent::SearchNewArtist(String::new()); + + let request_props = self + .request_props_from( + event, + RequestMethod::Get, + None::<()>, + None, + Some(format!("term={}", encode(&query))), + ) + .await; + + let result = self + .handle_request::<(), Vec>(request_props, |artist_vec, mut app| { + if artist_vec.is_empty() { + app.pop_and_push_navigation_stack(ActiveLidarrBlock::AddArtistEmptySearchResults.into()); + } else if let Some(add_searched_artists) = + app.data.lidarr_data.add_searched_artists.as_mut() + { + add_searched_artists.set_items(artist_vec); + } else { + let mut add_searched_artists = StatefulTable::default(); + add_searched_artists.set_items(artist_vec); + app.data.lidarr_data.add_searched_artists = Some(add_searched_artists); + } + }) + .await; + + if result.is_err() { + self.app.lock().await.data.lidarr_data.add_searched_artists = Some(StatefulTable::default()); + } + + result + } + pub(in crate::network::lidarr_network) async fn edit_artist( &mut self, mut edit_artist_params: EditArtistParams, diff --git a/src/network/lidarr_network/lidarr_network_test_utils.rs b/src/network/lidarr_network/lidarr_network_test_utils.rs index 4e6f9dc..2b05c7f 100644 --- a/src/network/lidarr_network/lidarr_network_test_utils.rs +++ b/src/network/lidarr_network/lidarr_network_test_utils.rs @@ -1,16 +1,28 @@ #[cfg(test)] -#[allow(dead_code)] // TODO: maybe remove? +#[allow(dead_code)] pub mod test_utils { use crate::models::HorizontallyScrollableText; use crate::models::lidarr_models::{ - Artist, ArtistStatistics, ArtistStatus, DownloadRecord, DownloadStatus, DownloadsResponse, - EditArtistParams, Member, MetadataProfile, NewItemMonitorType, Ratings, SystemStatus, + AddArtistSearchResult, Artist, ArtistStatistics, ArtistStatus, DownloadRecord, DownloadStatus, + DownloadsResponse, EditArtistParams, Member, MetadataProfile, NewItemMonitorType, Ratings, + SystemStatus, }; use crate::models::servarr_models::{QualityProfile, RootFolder, Tag}; use bimap::BiMap; use chrono::DateTime; use serde_json::Number; + pub const ADD_ARTIST_SEARCH_RESULT_JSON: &str = r#"{ + "foreignArtistId": "test-foreign-id", + "artistName": "Test Artist", + "status": "continuing", + "overview": "some interesting description of the artist", + "artistType": "Person", + "disambiguation": "American pianist", + "genres": ["soundtrack"], + "ratings": { "votes": 15, "value": 8.4 } + }"#; + pub const ARTIST_JSON: &str = r#"{ "id": 1, "artistName": "Test Artist", @@ -174,4 +186,17 @@ pub mod test_utils { clear_tags: false, } } + + pub fn add_artist_search_result() -> AddArtistSearchResult { + AddArtistSearchResult { + foreign_artist_id: "test-foreign-id".to_owned(), + artist_name: "Test Artist".into(), + status: ArtistStatus::Continuing, + overview: Some("some interesting description of the artist".to_owned()), + artist_type: Some("Person".to_owned()), + disambiguation: Some("American pianist".to_owned()), + genres: vec!["soundtrack".to_owned()], + ratings: Some(ratings()), + } + } } diff --git a/src/network/lidarr_network/mod.rs b/src/network/lidarr_network/mod.rs index 0ad2a62..3406cb8 100644 --- a/src/network/lidarr_network/mod.rs +++ b/src/network/lidarr_network/mod.rs @@ -39,6 +39,7 @@ pub enum LidarrEvent { GetTags, HealthCheck, ListArtists, + SearchNewArtist(String), ToggleArtistMonitoring(i64), UpdateAllArtists, } @@ -61,6 +62,7 @@ impl NetworkResource for LidarrEvent { LidarrEvent::GetRootFolders => "/rootfolder", LidarrEvent::GetStatus => "/system/status", LidarrEvent::HealthCheck => "/health", + LidarrEvent::SearchNewArtist(_) => "/artist/lookup", } } } @@ -121,6 +123,9 @@ impl Network<'_, '_> { .await .map(LidarrSerdeable::from), LidarrEvent::ListArtists => self.list_artists().await.map(LidarrSerdeable::from), + LidarrEvent::SearchNewArtist(query) => { + self.search_artist(query).await.map(LidarrSerdeable::from) + } LidarrEvent::ToggleArtistMonitoring(artist_id) => self .toggle_artist_monitoring(artist_id) .await diff --git a/src/network/radarr_network/indexers/mod.rs b/src/network/radarr_network/indexers/mod.rs index a2334f5..5e78413 100644 --- a/src/network/radarr_network/indexers/mod.rs +++ b/src/network/radarr_network/indexers/mod.rs @@ -406,9 +406,15 @@ impl Network<'_, '_> { .await; if result.is_err() { - self.app.lock().await.data.radarr_data.indexer_test_all_results = Some(StatefulTable::default()); + self + .app + .lock() + .await + .data + .radarr_data + .indexer_test_all_results = Some(StatefulTable::default()); } - + result } } diff --git a/src/network/radarr_network/indexers/radarr_indexers_network_tests.rs b/src/network/radarr_network/indexers/radarr_indexers_network_tests.rs index bc545d5..7a8b411 100644 --- a/src/network/radarr_network/indexers/radarr_indexers_network_tests.rs +++ b/src/network/radarr_network/indexers/radarr_indexers_network_tests.rs @@ -940,14 +940,16 @@ mod tests { async_server.assert_async().await; assert_err!(result); - assert_some!( - &app + assert_some!(&app.lock().await.data.radarr_data.indexer_test_all_results); + assert_is_empty!( + app .lock() .await .data .radarr_data .indexer_test_all_results + .as_ref() + .unwrap() ); - assert_is_empty!(app.lock().await.data.radarr_data.indexer_test_all_results.as_ref().unwrap()); } } diff --git a/src/network/radarr_network/library/radarr_library_network_tests.rs b/src/network/radarr_network/library/radarr_library_network_tests.rs index f91f2c9..bfcc1af 100644 --- a/src/network/radarr_network/library/radarr_library_network_tests.rs +++ b/src/network/radarr_network/library/radarr_library_network_tests.rs @@ -981,14 +981,7 @@ mod tests { ); async_server.assert_async().await; - assert_none!( - &app_arc - .lock() - .await - .data - .radarr_data - .add_searched_movies - ); + assert_none!(&app_arc.lock().await.data.radarr_data.add_searched_movies); assert_eq!( app_arc.lock().await.get_current_route(), ActiveRadarrBlock::AddMovieEmptySearchResults.into() @@ -1005,21 +998,23 @@ mod tests { .await; let mut network = test_network(&app_arc); - let result = network - .handle_radarr_event(RadarrEvent::SearchNewMovie("test term".into())) - .await; + let result = network + .handle_radarr_event(RadarrEvent::SearchNewMovie("test term".into())) + .await; async_server.assert_async().await; assert_err!(result); - assert_some!( - &app_arc + assert_some!(&app_arc.lock().await.data.radarr_data.add_searched_movies); + assert_is_empty!( + app_arc .lock() .await .data .radarr_data .add_searched_movies + .as_ref() + .unwrap() ); - assert_is_empty!(app_arc.lock().await.data.radarr_data.add_searched_movies.as_ref().unwrap()); } #[tokio::test] diff --git a/src/network/sonarr_network/indexers/mod.rs b/src/network/sonarr_network/indexers/mod.rs index 576a8c7..4581604 100644 --- a/src/network/sonarr_network/indexers/mod.rs +++ b/src/network/sonarr_network/indexers/mod.rs @@ -404,7 +404,13 @@ impl Network<'_, '_> { .await; if result.is_err() { - self.app.lock().await.data.sonarr_data.indexer_test_all_results = Some(StatefulTable::default()); + self + .app + .lock() + .await + .data + .sonarr_data + .indexer_test_all_results = Some(StatefulTable::default()); } result diff --git a/src/network/sonarr_network/indexers/sonarr_indexers_network_tests.rs b/src/network/sonarr_network/indexers/sonarr_indexers_network_tests.rs index f5512cf..fcb0032 100644 --- a/src/network/sonarr_network/indexers/sonarr_indexers_network_tests.rs +++ b/src/network/sonarr_network/indexers/sonarr_indexers_network_tests.rs @@ -901,12 +901,14 @@ mod tests { async_server.assert_async().await; assert_err!(result); let app = app.lock().await; - assert_some!( - &app + assert_some!(&app.data.sonarr_data.indexer_test_all_results); + assert_is_empty!( + app .data .sonarr_data .indexer_test_all_results + .as_ref() + .unwrap() ); - assert_is_empty!(app.data.sonarr_data.indexer_test_all_results.as_ref().unwrap()); } } diff --git a/src/network/sonarr_network/library/series/sonarr_series_network_tests.rs b/src/network/sonarr_network/library/series/sonarr_series_network_tests.rs index d4b83f3..26fd6ea 100644 --- a/src/network/sonarr_network/library/series/sonarr_series_network_tests.rs +++ b/src/network/sonarr_network/library/series/sonarr_series_network_tests.rs @@ -873,7 +873,6 @@ mod tests { .query("term=test%20term") .build_for(SonarrEvent::SearchNewSeries("test term".into())) .await; - app.lock().await.data.sonarr_data.add_series_search = Some("test term".into()); app.lock().await.server_tabs.next(); let mut network = test_network(&app); @@ -953,23 +952,15 @@ mod tests { app.lock().await.server_tabs.next(); let mut network = test_network(&app); - let result = - network - .handle_sonarr_event(SonarrEvent::SearchNewSeries("test term".into())) - .await; + let result = network + .handle_sonarr_event(SonarrEvent::SearchNewSeries("test term".into())) + .await; async_server.assert_async().await; assert_err!(result); let app = app.lock().await; - assert_some!( - &app - .data - .sonarr_data - .add_searched_series - ); - assert_is_empty!( - app.data.sonarr_data.add_searched_series.as_ref().unwrap() - ); + assert_some!(&app.data.sonarr_data.add_searched_series); + assert_is_empty!(app.data.sonarr_data.add_searched_series.as_ref().unwrap()); } #[tokio::test] diff --git a/src/ui/lidarr_ui/library/add_artist_ui.rs b/src/ui/lidarr_ui/library/add_artist_ui.rs new file mode 100644 index 0000000..497a98c --- /dev/null +++ b/src/ui/lidarr_ui/library/add_artist_ui.rs @@ -0,0 +1,151 @@ +use std::sync::atomic::Ordering; + +use ratatui::Frame; +use ratatui::layout::{Constraint, Layout, Rect}; +use ratatui::widgets::{Cell, Row}; + +use crate::App; +use crate::models::Route; +use crate::models::lidarr_models::AddArtistSearchResult; +use crate::models::servarr_data::lidarr::lidarr_data::{ADD_ARTIST_BLOCKS, ActiveLidarrBlock}; +use crate::ui::styles::ManagarrStyle; +use crate::ui::utils::{get_width_from_percentage, layout_block, title_block_centered}; +use crate::ui::widgets::input_box::InputBox; +use crate::ui::widgets::managarr_table::ManagarrTable; +use crate::ui::widgets::message::Message; +use crate::ui::widgets::popup::{Popup, Size}; +use crate::ui::{DrawUi, draw_popup}; + +#[cfg(test)] +#[path = "add_artist_ui_tests.rs"] +mod add_artist_ui_tests; + +pub(super) struct AddArtistUi; + +impl DrawUi for AddArtistUi { + fn accepts(route: Route) -> bool { + let Route::Lidarr(active_lidarr_block, _) = route else { + return false; + }; + ADD_ARTIST_BLOCKS.contains(&active_lidarr_block) + } + + fn draw(f: &mut Frame<'_>, app: &mut App<'_>, _area: Rect) { + draw_popup(f, app, draw_add_artist_search, Size::Large); + } +} + +fn draw_add_artist_search(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { + let is_loading = app.is_loading || app.data.lidarr_data.add_searched_artists.is_none(); + let current_selection = if let Some(add_searched_artists) = + app.data.lidarr_data.add_searched_artists.as_ref() + && !add_searched_artists.is_empty() + { + add_searched_artists.current_selection().clone() + } else { + AddArtistSearchResult::default() + }; + + let [search_box_area, results_area] = + Layout::vertical([Constraint::Length(3), Constraint::Fill(0)]) + .margin(1) + .areas(area); + let block_content = &app + .data + .lidarr_data + .add_artist_search + .as_ref() + .expect("add_artist_search must be populated") + .text; + let offset = app + .data + .lidarr_data + .add_artist_search + .as_ref() + .expect("add_artist_search must be populated") + .offset + .load(Ordering::SeqCst); + + let search_results_row_mapping = |artist: &AddArtistSearchResult| { + let rating = artist + .ratings + .as_ref() + .map_or(String::new(), |r| format!("{:.1}", r.value)); + let in_library = if app + .data + .lidarr_data + .artists + .items + .iter() + .any(|a| a.foreign_artist_id == artist.foreign_artist_id) + { + "✔" + } else { + "" + }; + + artist.artist_name.scroll_left_or_reset( + get_width_from_percentage(area, 27), + *artist == current_selection, + app.ui_scroll_tick_count == 0, + ); + + Row::new(vec![ + Cell::from(in_library), + Cell::from(artist.artist_name.to_string()), + Cell::from(artist.artist_type.clone().unwrap_or_default()), + Cell::from(artist.status.to_display_str()), + Cell::from(rating), + Cell::from(artist.genres.join(", ")), + ]) + .primary() + }; + + if let Route::Lidarr(active_lidarr_block, _) = app.get_current_route() { + match active_lidarr_block { + ActiveLidarrBlock::AddArtistSearchInput => { + let search_box = InputBox::new(block_content) + .offset(offset) + .block(title_block_centered("Add Artist")); + + search_box.show_cursor(f, search_box_area); + f.render_widget(layout_block().default(), results_area); + f.render_widget(search_box, search_box_area); + } + ActiveLidarrBlock::AddArtistEmptySearchResults => { + let error_message = Message::new("No artists found matching your query!"); + let error_message_popup = Popup::new(error_message).size(Size::Message); + + f.render_widget(layout_block().default(), results_area); + f.render_widget(error_message_popup, f.area()); + } + ActiveLidarrBlock::AddArtistSearchResults => { + let search_results_table = ManagarrTable::new( + app.data.lidarr_data.add_searched_artists.as_mut(), + search_results_row_mapping, + ) + .loading(is_loading) + .block(layout_block().default()) + .headers(["✔", "Name", "Type", "Status", "Rating", "Genres"]) + .constraints([ + Constraint::Percentage(3), + Constraint::Percentage(27), + Constraint::Percentage(12), + Constraint::Percentage(12), + Constraint::Percentage(8), + Constraint::Percentage(38), + ]); + + f.render_widget(search_results_table, results_area); + } + _ => (), + } + } + + f.render_widget( + InputBox::new(block_content) + .offset(offset) + .block(title_block_centered("Add Artist")), + search_box_area, + ); +} diff --git a/src/ui/lidarr_ui/library/add_artist_ui_tests.rs b/src/ui/lidarr_ui/library/add_artist_ui_tests.rs new file mode 100644 index 0000000..f756c07 --- /dev/null +++ b/src/ui/lidarr_ui/library/add_artist_ui_tests.rs @@ -0,0 +1,61 @@ +#[cfg(test)] +mod tests { + use strum::IntoEnumIterator; + + use crate::models::servarr_data::lidarr::lidarr_data::{ADD_ARTIST_BLOCKS, ActiveLidarrBlock}; + use crate::ui::DrawUi; + use crate::ui::lidarr_ui::library::add_artist_ui::AddArtistUi; + + #[test] + fn test_add_artist_ui_accepts() { + ActiveLidarrBlock::iter().for_each(|active_lidarr_block| { + if ADD_ARTIST_BLOCKS.contains(&active_lidarr_block) { + assert!(AddArtistUi::accepts(active_lidarr_block.into())); + } else { + assert!(!AddArtistUi::accepts(active_lidarr_block.into())); + } + }); + } + + mod snapshot_tests { + use super::*; + use crate::app::App; + use crate::models::HorizontallyScrollableText; + use crate::ui::ui_test_utils::test_utils::{TerminalSize, render_to_string_with_app}; + use rstest::rstest; + + #[test] + fn test_add_artist_ui_renders_loading_for_search() { + let mut app = App::test_default_fully_populated(); + app.data.lidarr_data.add_artist_search = Some(HorizontallyScrollableText::default()); + app.data.lidarr_data.add_searched_artists = None; + app.push_navigation_stack(ActiveLidarrBlock::AddArtistSearchResults.into()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + AddArtistUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(output); + } + + #[rstest] + fn test_add_artist_ui_renders( + #[values( + ActiveLidarrBlock::AddArtistSearchInput, + ActiveLidarrBlock::AddArtistSearchResults, + ActiveLidarrBlock::AddArtistEmptySearchResults + )] + active_lidarr_block: ActiveLidarrBlock, + ) { + let mut app = App::test_default_fully_populated(); + app.data.lidarr_data.add_artist_search = Some("Test Artist".into()); + app.push_navigation_stack(active_lidarr_block.into()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + AddArtistUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(format!("add_artist_ui_{active_lidarr_block}"), output); + } + } +} diff --git a/src/ui/lidarr_ui/library/library_ui_tests.rs b/src/ui/lidarr_ui/library/library_ui_tests.rs index 645be1c..040d96e 100644 --- a/src/ui/lidarr_ui/library/library_ui_tests.rs +++ b/src/ui/lidarr_ui/library/library_ui_tests.rs @@ -4,7 +4,7 @@ mod tests { use crate::models::lidarr_models::{Artist, ArtistStatistics, ArtistStatus}; use crate::models::servarr_data::lidarr::lidarr_data::{ - ActiveLidarrBlock, DELETE_ARTIST_BLOCKS, EDIT_ARTIST_BLOCKS, LIBRARY_BLOCKS, + ADD_ARTIST_BLOCKS, ActiveLidarrBlock, DELETE_ARTIST_BLOCKS, EDIT_ARTIST_BLOCKS, LIBRARY_BLOCKS, }; use crate::ui::DrawUi; use crate::ui::lidarr_ui::library::{LibraryUi, decorate_artist_row_with_style}; @@ -18,6 +18,7 @@ mod tests { library_ui_blocks.extend(LIBRARY_BLOCKS); library_ui_blocks.extend(DELETE_ARTIST_BLOCKS); library_ui_blocks.extend(EDIT_ARTIST_BLOCKS); + library_ui_blocks.extend(ADD_ARTIST_BLOCKS); for active_lidarr_block in ActiveLidarrBlock::iter() { if library_ui_blocks.contains(&active_lidarr_block) { diff --git a/src/ui/lidarr_ui/library/mod.rs b/src/ui/lidarr_ui/library/mod.rs index 57c2c6c..115c048 100644 --- a/src/ui/lidarr_ui/library/mod.rs +++ b/src/ui/lidarr_ui/library/mod.rs @@ -1,3 +1,4 @@ +use add_artist_ui::AddArtistUi; use delete_artist_ui::DeleteArtistUi; use edit_artist_ui::EditArtistUi; use ratatui::{ @@ -26,6 +27,7 @@ use crate::{ }, }; +mod add_artist_ui; mod delete_artist_ui; mod edit_artist_ui; @@ -38,7 +40,8 @@ pub(super) struct LibraryUi; impl DrawUi for LibraryUi { fn accepts(route: Route) -> bool { if let Route::Lidarr(active_lidarr_block, _) = route { - return DeleteArtistUi::accepts(route) + return AddArtistUi::accepts(route) + || DeleteArtistUi::accepts(route) || EditArtistUi::accepts(route) || LIBRARY_BLOCKS.contains(&active_lidarr_block); } @@ -51,6 +54,7 @@ impl DrawUi for LibraryUi { draw_library(f, app, area); match route { + _ if AddArtistUi::accepts(route) => AddArtistUi::draw(f, app, area), _ if DeleteArtistUi::accepts(route) => DeleteArtistUi::draw(f, app, area), _ if EditArtistUi::accepts(route) => EditArtistUi::draw(f, app, area), Route::Lidarr(ActiveLidarrBlock::UpdateAllArtistsPrompt, _) => { diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__add_artist_ui__add_artist_ui_tests__tests__snapshot_tests__AddArtistEmptySearchResults.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__add_artist_ui__add_artist_ui_tests__tests__snapshot_tests__AddArtistEmptySearchResults.snap new file mode 100644 index 0000000..68d5e7f --- /dev/null +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__add_artist_ui__add_artist_ui_tests__tests__snapshot_tests__AddArtistEmptySearchResults.snap @@ -0,0 +1,47 @@ +--- +source: src/ui/lidarr_ui/library/add_artist_ui_tests.rs +expression: output +--- + + + + + + + + ╭───────────────────────────────────────────────────── Add Artist ─────────────────────────────────────────────────────╮ + │Test Artist │ + ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ + ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ ╭─────────────── Error ───────────────╮ │ + │ │ No artists found matching your query! │ │ + │ │ │ │ + │ ╰───────────────────────────────────────╯ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__add_artist_ui__add_artist_ui_tests__tests__snapshot_tests__AddArtistSearchInput.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__add_artist_ui__add_artist_ui_tests__tests__snapshot_tests__AddArtistSearchInput.snap new file mode 100644 index 0000000..1f81b1d --- /dev/null +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__add_artist_ui__add_artist_ui_tests__tests__snapshot_tests__AddArtistSearchInput.snap @@ -0,0 +1,47 @@ +--- +source: src/ui/lidarr_ui/library/add_artist_ui_tests.rs +expression: output +--- + + + + + + + + ╭───────────────────────────────────────────────────── Add Artist ─────────────────────────────────────────────────────╮ + │Test Artist │ + ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ + ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__add_artist_ui__add_artist_ui_tests__tests__snapshot_tests__AddArtistSearchResults.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__add_artist_ui__add_artist_ui_tests__tests__snapshot_tests__AddArtistSearchResults.snap new file mode 100644 index 0000000..007dda8 --- /dev/null +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__add_artist_ui__add_artist_ui_tests__tests__snapshot_tests__AddArtistSearchResults.snap @@ -0,0 +1,47 @@ +--- +source: src/ui/lidarr_ui/library/add_artist_ui_tests.rs +expression: output +--- + + + + + + + + ╭───────────────────────────────────────────────────── Add Artist ─────────────────────────────────────────────────────╮ + │Test Artist │ + ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ + ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ ✔ Name Type Status Rating Genres │ + │=> Test Artist Person Continuing 8.4 soundtrack │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__add_artist_ui__add_artist_ui_tests__tests__snapshot_tests__add_artist_ui_AddArtistEmptySearchResults.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__add_artist_ui__add_artist_ui_tests__tests__snapshot_tests__add_artist_ui_AddArtistEmptySearchResults.snap new file mode 100644 index 0000000..68d5e7f --- /dev/null +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__add_artist_ui__add_artist_ui_tests__tests__snapshot_tests__add_artist_ui_AddArtistEmptySearchResults.snap @@ -0,0 +1,47 @@ +--- +source: src/ui/lidarr_ui/library/add_artist_ui_tests.rs +expression: output +--- + + + + + + + + ╭───────────────────────────────────────────────────── Add Artist ─────────────────────────────────────────────────────╮ + │Test Artist │ + ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ + ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ ╭─────────────── Error ───────────────╮ │ + │ │ No artists found matching your query! │ │ + │ │ │ │ + │ ╰───────────────────────────────────────╯ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__add_artist_ui__add_artist_ui_tests__tests__snapshot_tests__add_artist_ui_AddArtistSearchInput.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__add_artist_ui__add_artist_ui_tests__tests__snapshot_tests__add_artist_ui_AddArtistSearchInput.snap new file mode 100644 index 0000000..1f81b1d --- /dev/null +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__add_artist_ui__add_artist_ui_tests__tests__snapshot_tests__add_artist_ui_AddArtistSearchInput.snap @@ -0,0 +1,47 @@ +--- +source: src/ui/lidarr_ui/library/add_artist_ui_tests.rs +expression: output +--- + + + + + + + + ╭───────────────────────────────────────────────────── Add Artist ─────────────────────────────────────────────────────╮ + │Test Artist │ + ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ + ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__add_artist_ui__add_artist_ui_tests__tests__snapshot_tests__add_artist_ui_AddArtistSearchResults.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__add_artist_ui__add_artist_ui_tests__tests__snapshot_tests__add_artist_ui_AddArtistSearchResults.snap new file mode 100644 index 0000000..007dda8 --- /dev/null +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__add_artist_ui__add_artist_ui_tests__tests__snapshot_tests__add_artist_ui_AddArtistSearchResults.snap @@ -0,0 +1,47 @@ +--- +source: src/ui/lidarr_ui/library/add_artist_ui_tests.rs +expression: output +--- + + + + + + + + ╭───────────────────────────────────────────────────── Add Artist ─────────────────────────────────────────────────────╮ + │Test Artist │ + ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ + ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ ✔ Name Type Status Rating Genres │ + │=> Test Artist Person Continuing 8.4 soundtrack │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__add_artist_ui__add_artist_ui_tests__tests__snapshot_tests__add_artist_ui_renders_loading_for_search.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__add_artist_ui__add_artist_ui_tests__tests__snapshot_tests__add_artist_ui_renders_loading_for_search.snap new file mode 100644 index 0000000..3c61ebc --- /dev/null +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__add_artist_ui__add_artist_ui_tests__tests__snapshot_tests__add_artist_ui_renders_loading_for_search.snap @@ -0,0 +1,47 @@ +--- +source: src/ui/lidarr_ui/library/add_artist_ui_tests.rs +expression: output +--- + + + + + + + + ╭───────────────────────────────────────────────────── Add Artist ─────────────────────────────────────────────────────╮ + │ │ + ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ + ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ │ + │ │ + │ Loading ... │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ 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 ac1a912..3fc509d 100644 --- a/src/ui/radarr_ui/indexers/test_all_indexers_ui.rs +++ b/src/ui/radarr_ui/indexers/test_all_indexers_ui.rs @@ -34,12 +34,14 @@ fn draw_test_all_indexers_test_results(f: &mut Frame<'_>, app: &mut App<'_>, are let is_loading = app.is_loading || app.data.radarr_data.indexer_test_all_results.is_none(); let block = title_block("Test All Indexers"); - let current_selection = - if let Some(test_all_results) = app.data.radarr_data.indexer_test_all_results.as_ref() && !test_all_results.is_empty() { - test_all_results.current_selection().clone() - } else { - IndexerTestResultModalItem::default() - }; + let current_selection = if let Some(test_all_results) = + app.data.radarr_data.indexer_test_all_results.as_ref() + && !test_all_results.is_empty() + { + test_all_results.current_selection().clone() + } else { + IndexerTestResultModalItem::default() + }; f.render_widget(block, area); let test_results_row_mapping = |result: &IndexerTestResultModalItem| { result.validation_failures.scroll_left_or_reset( diff --git a/src/ui/sonarr_ui/indexers/test_all_indexers_ui.rs b/src/ui/sonarr_ui/indexers/test_all_indexers_ui.rs index bfed06a..bc02ed8 100644 --- a/src/ui/sonarr_ui/indexers/test_all_indexers_ui.rs +++ b/src/ui/sonarr_ui/indexers/test_all_indexers_ui.rs @@ -33,12 +33,14 @@ impl DrawUi for TestAllIndexersUi { fn draw_test_all_indexers_test_results(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { let is_loading = app.is_loading || app.data.sonarr_data.indexer_test_all_results.is_none(); - let current_selection = - if let Some(test_all_results) = app.data.sonarr_data.indexer_test_all_results.as_ref() && !test_all_results.is_empty() { - test_all_results.current_selection().clone() - } else { - IndexerTestResultModalItem::default() - }; + let current_selection = if let Some(test_all_results) = + app.data.sonarr_data.indexer_test_all_results.as_ref() + && !test_all_results.is_empty() + { + test_all_results.current_selection().clone() + } else { + IndexerTestResultModalItem::default() + }; f.render_widget(title_block("Test All Indexers"), area); let test_results_row_mapping = |result: &IndexerTestResultModalItem| { result.validation_failures.scroll_left_or_reset( -- 2.52.0 From f0ed71b4364d7b103db8332788cc41878df9be44 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Wed, 7 Jan 2026 17:15:54 -0700 Subject: [PATCH 22/61] build: Upgraded to Ratatui v0.30.0 and fixed a new security vulnerability [#13] --- Cargo.lock | 1434 ++++++++++++----- Cargo.toml | 4 +- src/ui/lidarr_ui/library/add_artist_ui.rs | 6 +- src/ui/lidarr_ui/library/edit_artist_ui.rs | 11 +- src/ui/lidarr_ui/mod.rs | 4 +- src/ui/mod.rs | 10 +- src/ui/radarr_ui/blocklist/mod.rs | 6 +- .../collections/collection_details_ui.rs | 17 +- .../collections/edit_collection_ui.rs | 9 +- src/ui/radarr_ui/indexers/edit_indexer_ui.rs | 5 +- .../radarr_ui/indexers/indexer_settings_ui.rs | 5 +- src/ui/radarr_ui/indexers/mod.rs | 4 +- src/ui/radarr_ui/library/add_movie_ui.rs | 18 +- src/ui/radarr_ui/library/edit_movie_ui.rs | 9 +- src/ui/radarr_ui/library/movie_details_ui.rs | 18 +- .../library/movie_details_ui_tests.rs | 19 +- src/ui/radarr_ui/mod.rs | 4 +- src/ui/radarr_ui/system/mod.rs | 4 +- src/ui/sonarr_ui/blocklist/mod.rs | 6 +- src/ui/sonarr_ui/history/mod.rs | 5 +- src/ui/sonarr_ui/indexers/edit_indexer_ui.rs | 5 +- .../sonarr_ui/indexers/indexer_settings_ui.rs | 5 +- src/ui/sonarr_ui/indexers/mod.rs | 4 +- src/ui/sonarr_ui/library/add_series_ui.rs | 20 +- src/ui/sonarr_ui/library/edit_series_ui.rs | 11 +- .../sonarr_ui/library/episode_details_ui.rs | 20 +- src/ui/sonarr_ui/library/season_details_ui.rs | 5 +- src/ui/sonarr_ui/library/series_details_ui.rs | 35 +- src/ui/sonarr_ui/mod.rs | 4 +- src/ui/sonarr_ui/system/mod.rs | 4 +- src/ui/styles.rs | 476 +++--- src/ui/styles_tests.rs | 48 +- src/ui/utils.rs | 42 +- src/ui/utils_tests.rs | 27 +- src/ui/widgets/checkbox.rs | 1 - src/ui/widgets/confirmation_prompt.rs | 9 +- src/ui/widgets/input_box.rs | 7 +- src/ui/widgets/input_box_tests.rs | 5 +- src/ui/widgets/managarr_table.rs | 11 +- src/ui/widgets/message.rs | 6 +- src/ui/widgets/message_tests.rs | 5 +- src/ui/widgets/selectable_list.rs | 4 +- src/ui/widgets/selectable_list_tests.rs | 8 +- 43 files changed, 1532 insertions(+), 828 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ccbfb24..992637e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -99,9 +99,12 @@ checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" [[package]] name = "arc-swap" -version = "1.7.1" +version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69f7f8c3906b62b754cd5326047894316021dcfe5a194c8ea52bdd94934a3457" +checksum = "51d03449bb8ca2cc2ef70869af31463d1ae5ccc8fa3e334b307203fbf815207e" +dependencies = [ + "rustversion", +] [[package]] name = "assert-json-diff" @@ -130,9 +133,9 @@ dependencies = [ [[package]] name = "assertables" -version = "9.8.2" +version = "9.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59051ec02907378a67b0ba1b8631121f5388c8dbbb3cec8c749d8f93c2c3c211" +checksum = "cbada39b42413d4db3d9460f6e791702490c40f72924378a1b6fc1a4181188fd" [[package]] name = "async-trait" @@ -142,7 +145,16 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.114", +] + +[[package]] +name = "atomic" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89cbf775b137e9b968e67227ef7f775587cde3fd31b0d8599dbd0f598a48340" +dependencies = [ + "bytemuck", ] [[package]] @@ -169,7 +181,7 @@ dependencies = [ "miniz_oxide", "object", "rustc-demangle", - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -187,27 +199,57 @@ dependencies = [ "serde", ] +[[package]] +name = "bit-set" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1" +dependencies = [ + "bit-vec 0.6.3", +] + [[package]] name = "bit-set" version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" dependencies = [ - "bit-vec", + "bit-vec 0.8.0", ] +[[package]] +name = "bit-vec" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" + [[package]] name = "bit-vec" version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + [[package]] name = "bitflags" version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + [[package]] name = "block2" version = "0.6.2" @@ -230,9 +272,15 @@ dependencies = [ [[package]] name = "bumpalo" -version = "3.19.0" +version = "3.19.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" +checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" + +[[package]] +name = "bytemuck" +version = "1.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbdf580320f38b612e485521afda1ee26d10cc9884efaaa750d383e13e3c5f4" [[package]] name = "bytes" @@ -246,12 +294,6 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7b02b629252fe8ef6460461409564e2c21d0c8e77e0944f3d189ff06c4e932ad" -[[package]] -name = "cassowary" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" - [[package]] name = "castaway" version = "0.2.4" @@ -263,9 +305,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.49" +version = "1.2.51" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90583009037521a116abf44494efecd645ba48b6622457080f080b85544e2215" +checksum = "7a0aeaff4ff1a90589618835a598e545176939b97874f7abc7851caa0618f203" dependencies = [ "find-msvc-tools", "shlex", @@ -294,14 +336,14 @@ dependencies = [ "num-traits", "serde", "wasm-bindgen", - "windows-link", + "windows-link 0.2.1", ] [[package]] name = "clap" -version = "4.5.53" +version = "4.5.54" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c9e340e012a1bf4935f5282ed1436d1489548e8f72308207ea5df0e23d2d03f8" +checksum = "c6e6ff9dcd79cff5cd969a17a545d79e84ab086e444102a591e288a8aa3ce394" dependencies = [ "clap_builder", "clap_derive", @@ -309,9 +351,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.53" +version = "4.5.54" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d76b5d13eaa18c901fd2f7fca939fefe3a0727a953561fefdf3b2922b8569d00" +checksum = "fa42cf4d2b7a41bc8f663a7cab4031ebafa1bf3875705bfaf8466dc60ab52c00" dependencies = [ "anstream", "anstyle", @@ -322,9 +364,9 @@ dependencies = [ [[package]] name = "clap_complete" -version = "4.5.61" +version = "4.5.65" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39615915e2ece2550c0149addac32fb5bd312c657f43845bb9088cb9c8a7c992" +checksum = "430b4dc2b5e3861848de79627b2bedc9f3342c7da5173a14eaa5d0f8dc18ae5d" dependencies = [ "clap", ] @@ -338,7 +380,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.114", ] [[package]] @@ -364,9 +406,9 @@ dependencies = [ [[package]] name = "compact_str" -version = "0.8.1" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b79c4069c6cad78e2e0cdfcbd26275770669fb39fd308a752dc110e83b9af32" +checksum = "3fdb1325a1cece981e8a296ab8f0f9b63ae357bd0784a9faaf548cc7b480707a" dependencies = [ "castaway", "cfg-if", @@ -397,10 +439,19 @@ dependencies = [ "encode_unicode", "libc", "once_cell", - "unicode-width 0.2.0", + "unicode-width", "windows-sys 0.59.0", ] +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + [[package]] name = "core-foundation" version = "0.9.4" @@ -417,13 +468,22 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + [[package]] name = "crossterm" version = "0.28.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" dependencies = [ - "bitflags", + "bitflags 2.10.0", "crossterm_winapi", "mio", "parking_lot", @@ -433,6 +493,24 @@ dependencies = [ "winapi", ] +[[package]] +name = "crossterm" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" +dependencies = [ + "bitflags 2.10.0", + "crossterm_winapi", + "derive_more", + "document-features", + "mio", + "parking_lot", + "rustix 1.1.3", + "signal-hook", + "signal-hook-mio", + "winapi", +] + [[package]] name = "crossterm_winapi" version = "0.9.1" @@ -442,6 +520,26 @@ dependencies = [ "winapi", ] +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "csscolorparser" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb2a7d3066da2de787b7f032c736763eb7ae5d355f81a68bab2675a96008b0bf" +dependencies = [ + "lab", + "phf", +] + [[package]] name = "ctrlc" version = "3.5.1" @@ -449,7 +547,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "73736a89c4aff73035ba2ed2e565061954da00d4970fc9ac25dcc85a2a20d790" dependencies = [ "dispatch2", - "nix", + "nix 0.30.1", "windows-sys 0.61.2", ] @@ -459,8 +557,18 @@ version = "0.20.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" dependencies = [ - "darling_core", - "darling_macro", + "darling_core 0.20.11", + "darling_macro 0.20.11", +] + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core 0.23.0", + "darling_macro 0.23.0", ] [[package]] @@ -474,7 +582,20 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn 2.0.111", + "syn 2.0.114", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.114", ] [[package]] @@ -483,11 +604,28 @@ version = "0.20.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" dependencies = [ - "darling_core", + "darling_core 0.20.11", "quote", - "syn 2.0.111", + "syn 2.0.114", ] +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core 0.23.0", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "deltae" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5729f5117e208430e437df2f4843f5e5952997175992d1414f94c57d61e270b4" + [[package]] name = "deranged" version = "0.5.5" @@ -510,23 +648,24 @@ dependencies = [ [[package]] name = "derive_more" -version = "2.1.0" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10b768e943bed7bf2cab53df09f4bc34bfd217cdb57d971e769874c9a6710618" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" dependencies = [ "derive_more-impl", ] [[package]] name = "derive_more-impl" -version = "2.1.0" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d286bfdaf75e988b4a78e013ecd79c581e06399ab53fbacd2d916c2f904f30b" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" dependencies = [ + "convert_case", "proc-macro2", "quote", "rustc_version", - "syn 2.0.111", + "syn 2.0.114", "unicode-xid", ] @@ -536,10 +675,10 @@ version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ae5c625eda104c228c06ecaf988d1c60e542176bd7a490e60eeda3493244c0c9" dependencies = [ - "darling", + "darling 0.20.11", "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.114", ] [[package]] @@ -566,6 +705,16 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8" +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + [[package]] name = "directories" version = "5.0.1" @@ -614,7 +763,7 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec" dependencies = [ - "bitflags", + "bitflags 2.10.0", "block2", "libc", "objc2", @@ -628,7 +777,16 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.114", +] + +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", ] [[package]] @@ -664,18 +822,18 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "583f1f514d2754010ff71ed6853068cacbe43cc142cc076aa1b871d9754efc48" dependencies = [ - "darling", + "darling 0.20.11", "quote", - "syn 2.0.111", + "syn 2.0.114", ] [[package]] name = "enum_display_style_derive" version = "0.6.1" dependencies = [ - "darling", + "darling 0.20.11", "quote", - "syn 2.0.111", + "syn 2.0.114", ] [[package]] @@ -694,6 +852,25 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "euclid" +version = "0.22.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad9cdb4b747e485a12abb0e6566612956c7a1bafa3bdb8d682c5b6d403589e48" +dependencies = [ + "num-traits", +] + +[[package]] +name = "fancy-regex" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b95f7c0680e4142284cf8b22c14a476e87d61b004a3a0861872b32ef7ead40a2" +dependencies = [ + "bit-set 0.5.3", + "regex", +] + [[package]] name = "fastrand" version = "2.3.0" @@ -701,10 +878,33 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" [[package]] -name = "find-msvc-tools" -version = "0.1.5" +name = "filedescriptor" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a3076410a55c90011c298b04d0cfa770b00fa04e1e3c97d3f6c9de105a03844" +checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d" +dependencies = [ + "libc", + "thiserror 1.0.69", + "winapi", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "645cbb3a84e60b7531617d5ae4e57f7e27308f6445f5abf653209ea76dec8dff" + +[[package]] +name = "finl_unicode" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9844ddc3a6e533d62bba727eb6c28b5d360921d5175e9ff0f1e621a5c590a4d5" + +[[package]] +name = "fixedbitset" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" [[package]] name = "fnv" @@ -714,9 +914,9 @@ checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" [[package]] name = "foldhash" -version = "0.1.5" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" [[package]] name = "foreign-types" @@ -748,21 +948,6 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28dd6caf6059519a65843af8fe2a3ae298b14b80179855aeb4adc2c1934ee619" -[[package]] -name = "futures" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" -dependencies = [ - "futures-channel", - "futures-core", - "futures-executor", - "futures-io", - "futures-sink", - "futures-task", - "futures-util", -] - [[package]] name = "futures-channel" version = "0.3.31" @@ -770,7 +955,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" dependencies = [ "futures-core", - "futures-sink", ] [[package]] @@ -790,12 +974,6 @@ dependencies = [ "futures-util", ] -[[package]] -name = "futures-io" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" - [[package]] name = "futures-macro" version = "0.3.31" @@ -804,7 +982,7 @@ checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.114", ] [[package]] @@ -831,18 +1009,24 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" dependencies = [ - "futures-channel", "futures-core", - "futures-io", "futures-macro", - "futures-sink", "futures-task", - "memchr", "pin-project-lite", "pin-utils", "slab", ] +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + [[package]] name = "getrandom" version = "0.2.16" @@ -880,9 +1064,9 @@ checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" [[package]] name = "h2" -version = "0.4.12" +version = "0.4.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3c0b69cfcb4e1b9f1bf2f53f95f766e4661169728ec61cd3fe5a0166f2d1386" +checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" dependencies = [ "atomic-waker", "bytes", @@ -899,27 +1083,27 @@ dependencies = [ [[package]] name = "hashbrown" -version = "0.15.5" +version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" dependencies = [ "allocator-api2", "equivalent", "foldhash", ] -[[package]] -name = "hashbrown" -version = "0.16.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" - [[package]] name = "heck" version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + [[package]] name = "http" version = "1.4.0" @@ -967,16 +1151,16 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" [[package]] name = "human-panic" -version = "2.0.4" +version = "2.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8a07a0957cd4a3cad4a1e4ca7cd5ea07fcacef6ebe2e5d0c7935bfc95120d8" +checksum = "075e8747af11abcff07d55d98297c9c6c70eb5d6365b25e7b12f02e484935191" dependencies = [ "anstream", "anstyle", "backtrace", - "os_info", "serde", "serde_derive", + "sysinfo", "toml", "uuid", ] @@ -1080,7 +1264,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core", + "windows-core 0.62.2", ] [[package]] @@ -1202,12 +1386,12 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.12.1" +version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ad4bb2b565bca0645f4d68c5c9af97fba094e9791da685bf83cb5f3ce74acf2" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" dependencies = [ "equivalent", - "hashbrown 0.16.1", + "hashbrown", ] [[package]] @@ -1219,7 +1403,7 @@ dependencies = [ "console", "number_prefix", "portable-atomic", - "unicode-width 0.2.0", + "unicode-width", "web-time", ] @@ -1234,26 +1418,27 @@ dependencies = [ [[package]] name = "insta" -version = "1.44.3" +version = "1.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5c943d4415edd8153251b6f197de5eb1640e56d84e8d9159bea190421c73698" +checksum = "1b66886d14d18d420ab5052cbff544fc5d34d0b2cdd35eb5976aaa10a4a472e5" dependencies = [ "console", "once_cell", "similar", + "tempfile", ] [[package]] name = "instability" -version = "0.3.10" +version = "0.3.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6778b0196eefee7df739db78758e5cf9b37412268bfa5650bfeed028aed20d9c" +checksum = "357b7205c6cd18dd2c86ed312d1e70add149aea98e7ef72b9fdf0270e555c11d" dependencies = [ - "darling", + "darling 0.23.0", "indoc", "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.114", ] [[package]] @@ -1264,9 +1449,9 @@ checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" [[package]] name = "iri-string" -version = "0.7.9" +version = "0.7.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f867b9d1d896b67beb18518eda36fdb77a32ea590de864f1325b294a6d14397" +checksum = "c91338f0783edbd6195decb37bae672fd3b165faffb89bf7b9e6942f8b1a731a" dependencies = [ "memchr", "serde", @@ -1298,9 +1483,9 @@ dependencies = [ [[package]] name = "itoa" -version = "1.0.15" +version = "1.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" [[package]] name = "js-sys" @@ -1313,21 +1498,53 @@ dependencies = [ ] [[package]] -name = "libc" -version = "0.2.178" +name = "kasuari" +version = "0.4.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37c93d8daa9d8a012fd8ab92f088405fb202ea0b6ab73ee2482ae66af4f42091" +checksum = "8fe90c1150662e858c7d5f945089b7517b0a80d8bf7ba4b1b5ffc984e7230a5b" +dependencies = [ + "hashbrown", + "portable-atomic", + "thiserror 2.0.17", +] + +[[package]] +name = "lab" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf36173d4167ed999940f804952e6b08197cae5ad5d572eb4db150ce8ad5d58f" + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.179" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5a2d376baa530d1238d133232d15e239abad80d05838b4b59354e5268af431f" [[package]] name = "libredox" -version = "0.1.10" +version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "416f7e718bdb06000964960ffa43b4335ad4012ae8b99060261aa4a8088d5ccb" +checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616" dependencies = [ - "bitflags", + "bitflags 2.10.0", "libc", ] +[[package]] +name = "line-clipping" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f4de44e98ddbf09375cbf4d17714d18f39195f4f4894e8524501726fd9a8a4a" +dependencies = [ + "bitflags 2.10.0", +] + [[package]] name = "linux-raw-sys" version = "0.4.15" @@ -1346,6 +1563,12 @@ version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + [[package]] name = "lock_api" version = "0.4.14" @@ -1387,7 +1610,7 @@ dependencies = [ "log-mdc", "mock_instant", "parking_lot", - "rand", + "rand 0.9.2", "serde", "serde-value", "serde_json", @@ -1401,11 +1624,21 @@ dependencies = [ [[package]] name = "lru" -version = "0.12.5" +version = "0.16.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" +checksum = "a1dc47f592c06f33f8e3aea9591776ec7c9f9e4124778ff8a3c3b87159f7e593" dependencies = [ - "hashbrown 0.15.5", + "hashbrown", +] + +[[package]] +name = "mac_address" +version = "1.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0aeb26bf5e836cc1c341c8106051b573f1766dfa05aa87f0b98be5e51b02303" +dependencies = [ + "nix 0.29.0", + "winapi", ] [[package]] @@ -1424,7 +1657,7 @@ dependencies = [ "clap_complete", "colored", "confy", - "crossterm", + "crossterm 0.28.1", "ctrlc", "derivative", "derive_setters", @@ -1452,8 +1685,8 @@ dependencies = [ "serde_json", "serde_yaml", "serial_test", - "strum", - "strum_macros", + "strum 0.26.3", + "strum_macros 0.26.4", "tokio", "tokio-util", "urlencoding", @@ -1463,12 +1696,12 @@ dependencies = [ [[package]] name = "managarr-tree-widget" -version = "0.24.0" +version = "0.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aae8e5f28f9581dcddb67e4741a96231752dafb997224cae6d42c75db29eb5af" +checksum = "b1d56114c2ca4bb81c0e68f1d1c71ad3d4dd839f851ce1873d6256817d1d7d1b" dependencies = [ "ratatui", - "unicode-width 0.2.0", + "unicode-width", ] [[package]] @@ -1477,12 +1710,33 @@ version = "2.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" +[[package]] +name = "memmem" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a64a92489e2744ce060c349162be1c5f33c6969234104dbd99ddb5feb08b8c15" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + [[package]] name = "mime" version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -1533,7 +1787,7 @@ dependencies = [ "cfg-if", "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.114", ] [[package]] @@ -1553,7 +1807,7 @@ dependencies = [ "hyper-util", "log", "pin-project-lite", - "rand", + "rand 0.9.2", "regex", "serde_json", "serde_urlencoded", @@ -1578,24 +1832,67 @@ dependencies = [ "tempfile", ] +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags 2.10.0", + "cfg-if", + "cfg_aliases", + "libc", + "memoffset", +] + [[package]] name = "nix" version = "0.30.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" dependencies = [ - "bitflags", + "bitflags 2.10.0", "cfg-if", "cfg_aliases", "libc", ] +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "ntapi" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c70f219e21142367c70c0b30c6a9e3a14d55b4d12a204d897fbec83a0363f081" +dependencies = [ + "winapi", +] + [[package]] name = "num-conv" version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -1629,81 +1926,13 @@ dependencies = [ "objc2-encode", ] -[[package]] -name = "objc2-cloud-kit" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c" -dependencies = [ - "bitflags", - "objc2", - "objc2-foundation", -] - -[[package]] -name = "objc2-core-data" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" -dependencies = [ - "objc2", - "objc2-foundation", -] - [[package]] name = "objc2-core-foundation" version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ - "bitflags", - "dispatch2", - "objc2", -] - -[[package]] -name = "objc2-core-graphics" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" -dependencies = [ - "bitflags", - "dispatch2", - "objc2", - "objc2-core-foundation", - "objc2-io-surface", -] - -[[package]] -name = "objc2-core-image" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006" -dependencies = [ - "objc2", - "objc2-foundation", -] - -[[package]] -name = "objc2-core-location" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca347214e24bc973fc025fd0d36ebb179ff30536ed1f80252706db19ee452009" -dependencies = [ - "objc2", - "objc2-foundation", -] - -[[package]] -name = "objc2-core-text" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" -dependencies = [ - "bitflags", - "objc2", - "objc2-core-foundation", - "objc2-core-graphics", + "bitflags 2.10.0", ] [[package]] @@ -1713,72 +1942,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" [[package]] -name = "objc2-foundation" +name = "objc2-io-kit" version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +checksum = "33fafba39597d6dc1fb709123dfa8289d39406734be322956a69f0931c73bb15" dependencies = [ - "bitflags", - "block2", "libc", - "objc2", "objc2-core-foundation", ] -[[package]] -name = "objc2-io-surface" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" -dependencies = [ - "bitflags", - "objc2", - "objc2-core-foundation", -] - -[[package]] -name = "objc2-quartz-core" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" -dependencies = [ - "bitflags", - "objc2", - "objc2-core-foundation", - "objc2-foundation", -] - -[[package]] -name = "objc2-ui-kit" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" -dependencies = [ - "bitflags", - "block2", - "objc2", - "objc2-cloud-kit", - "objc2-core-data", - "objc2-core-foundation", - "objc2-core-graphics", - "objc2-core-image", - "objc2-core-location", - "objc2-core-text", - "objc2-foundation", - "objc2-quartz-core", - "objc2-user-notifications", -] - -[[package]] -name = "objc2-user-notifications" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9df9128cbbfef73cda168416ccf7f837b62737d748333bfe9ab71c245d76613e" -dependencies = [ - "objc2", - "objc2-foundation", -] - [[package]] name = "object" version = "0.37.3" @@ -1806,7 +1978,7 @@ version = "0.10.75" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328" dependencies = [ - "bitflags", + "bitflags 2.10.0", "cfg-if", "foreign-types", "libc", @@ -1823,7 +1995,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.114", ] [[package]] @@ -1870,19 +2042,12 @@ dependencies = [ ] [[package]] -name = "os_info" -version = "3.14.0" +name = "ordered-float" +version = "4.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e4022a17595a00d6a369236fdae483f0de7f0a339960a53118b818238e132224" +checksum = "7bb71e1b3fa6ca1c61f383464aaf2bb0e2f8e772a1f01d486832464de363b951" dependencies = [ - "android_system_properties", - "log", - "nix", - "objc2", - "objc2-foundation", - "objc2-ui-kit", - "serde", - "windows-sys 0.61.2", + "num-traits", ] [[package]] @@ -1905,21 +2070,110 @@ dependencies = [ "libc", "redox_syscall", "smallvec", - "windows-link", + "windows-link 0.2.1", ] -[[package]] -name = "paste" -version = "1.0.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" - [[package]] name = "percent-encoding" version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "pest" +version = "2.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9eb05c21a464ea704b53158d358a31e6425db2f63a1a7312268b05fe2b75f7" +dependencies = [ + "memchr", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68f9dbced329c441fa79d80472764b1a2c7e57123553b8519b36663a2fb234ed" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3bb96d5051a78f44f43c8f712d8e810adb0ebf923fc9ed2655a7f66f63ba8ee5" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "pest_meta" +version = "2.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "602113b5b5e8621770cfd490cfd90b9f84ab29bd2b0e49ad83eb6d186cef2365" +dependencies = [ + "pest", + "sha2", +] + +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_macros", + "phf_shared", +] + +[[package]] +name = "phf_codegen" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" +dependencies = [ + "phf_generator", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared", + "rand 0.8.5", +] + +[[package]] +name = "phf_macros" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher", +] + [[package]] name = "pin-project-lite" version = "0.2.16" @@ -1940,9 +2194,9 @@ checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" [[package]] name = "portable-atomic" -version = "1.11.1" +version = "1.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f84267b20a16ea918e43c6a88433c2d54fa145c92a811b5b047ccbe153674483" +checksum = "f89776e4d69bb58bc6993e99ffa1d11f228b839984854c7daeb5d37f87cbe950" [[package]] name = "potential_utf" @@ -2016,9 +2270,9 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.103" +version = "1.0.105" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8" +checksum = "535d180e0ecab6268a3e718bb9fd44db66bbbc256257165fc699dadf70d16fe7" dependencies = [ "unicode-ident", ] @@ -2029,11 +2283,11 @@ version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bee689443a2bd0a16ab0348b52ee43e3b2d1b1f931c8aa5c9f8de4c86fbe8c40" dependencies = [ - "bit-set", - "bit-vec", - "bitflags", + "bit-set 0.8.0", + "bit-vec 0.8.0", + "bitflags 2.10.0", "num-traits", - "rand", + "rand 0.9.2", "rand_chacha", "rand_xorshift", "regex-syntax", @@ -2050,9 +2304,9 @@ checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" [[package]] name = "quote" -version = "1.0.42" +version = "1.0.43" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f" +checksum = "dc74d9a594b72ae6656596548f56f667211f8a97b3d4c3d467150794690dc40a" dependencies = [ "proc-macro2", ] @@ -2063,6 +2317,15 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "rand_core 0.6.4", +] + [[package]] name = "rand" version = "0.9.2" @@ -2070,7 +2333,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" dependencies = [ "rand_chacha", - "rand_core", + "rand_core 0.9.3", ] [[package]] @@ -2080,9 +2343,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" dependencies = [ "ppv-lite86", - "rand_core", + "rand_core 0.9.3", ] +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" + [[package]] name = "rand_core" version = "0.9.3" @@ -2098,29 +2367,92 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" dependencies = [ - "rand_core", + "rand_core 0.9.3", ] [[package]] name = "ratatui" -version = "0.29.0" +version = "0.30.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b" +checksum = "d1ce67fb8ba4446454d1c8dbaeda0557ff5e94d39d5e5ed7f10a65eb4c8266bc" dependencies = [ - "bitflags", - "cassowary", - "compact_str", - "crossterm", - "indoc", "instability", - "itertools 0.13.0", + "ratatui-core", + "ratatui-crossterm", + "ratatui-macros", + "ratatui-termwiz", + "ratatui-widgets", +] + +[[package]] +name = "ratatui-core" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ef8dea09a92caaf73bff7adb70b76162e5937524058a7e5bff37869cbbec293" +dependencies = [ + "bitflags 2.10.0", + "compact_str", + "hashbrown", + "indoc", + "itertools 0.14.0", + "kasuari", "lru", - "paste", - "strum", - "time", + "strum 0.27.2", + "thiserror 2.0.17", "unicode-segmentation", "unicode-truncate", - "unicode-width 0.2.0", + "unicode-width", +] + +[[package]] +name = "ratatui-crossterm" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "577c9b9f652b4c121fb25c6a391dd06406d3b092ba68827e6d2f09550edc54b3" +dependencies = [ + "cfg-if", + "crossterm 0.29.0", + "instability", + "ratatui-core", +] + +[[package]] +name = "ratatui-macros" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7f1342a13e83e4bb9d0b793d0ea762be633f9582048c892ae9041ef39c936f4" +dependencies = [ + "ratatui-core", + "ratatui-widgets", +] + +[[package]] +name = "ratatui-termwiz" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f76fe0bd0ed4295f0321b1676732e2454024c15a35d01904ddb315afd3d545c" +dependencies = [ + "ratatui-core", + "termwiz", +] + +[[package]] +name = "ratatui-widgets" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7dbfa023cd4e604c2553483820c5fe8aa9d71a42eea5aa77c6e7f35756612db" +dependencies = [ + "bitflags 2.10.0", + "hashbrown", + "indoc", + "instability", + "itertools 0.14.0", + "line-clipping", + "ratatui-core", + "strum 0.27.2", + "time", + "unicode-segmentation", + "unicode-width", ] [[package]] @@ -2129,7 +2461,7 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags", + "bitflags 2.10.0", ] [[package]] @@ -2180,9 +2512,9 @@ checksum = "ba39f3699c378cd8970968dcbff9c43159ea4cfbd88d43c00b22f2ef10a435d2" [[package]] name = "reqwest" -version = "0.12.25" +version = "0.12.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6eff9328d40131d43bd911d42d79eb6a47312002a4daefc9e37f17e74a7701a" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ "base64", "bytes", @@ -2258,7 +2590,7 @@ dependencies = [ "regex", "relative-path", "rustc_version", - "syn 2.0.111", + "syn 2.0.114", "unicode-ident", ] @@ -2283,7 +2615,7 @@ version = "0.38.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" dependencies = [ - "bitflags", + "bitflags 2.10.0", "errno", "libc", "linux-raw-sys 0.4.15", @@ -2292,11 +2624,11 @@ dependencies = [ [[package]] name = "rustix" -version = "1.1.2" +version = "1.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e" +checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" dependencies = [ - "bitflags", + "bitflags 2.10.0", "errno", "libc", "linux-raw-sys 0.11.0", @@ -2305,9 +2637,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.35" +version = "0.23.36" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "533f54bc6a7d4f647e46ad909549eda97bf5afc1585190ef692b4286b198bd8f" +checksum = "c665f33d38cea657d9614f766881e4d510e0eda4239891eea56b4cadcf01801b" dependencies = [ "once_cell", "rustls-pki-types", @@ -2318,9 +2650,9 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.13.1" +version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "708c0f9d5f54ba0272468c1d306a52c495b31fa155e91bc25371e6df7996908c" +checksum = "21e6f2ab2928ca4291b86736a8bd920a277a399bba1589409d72154ff87c1282" dependencies = [ "zeroize", ] @@ -2356,9 +2688,9 @@ dependencies = [ [[package]] name = "ryu" -version = "1.0.20" +version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" +checksum = "a50f4cf475b65d88e057964e0e9bb1f0aa9bbb2036dc65c64596b42932536984" [[package]] name = "scc" @@ -2396,7 +2728,7 @@ version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" dependencies = [ - "bitflags", + "bitflags 2.10.0", "core-foundation", "core-foundation-sys", "libc", @@ -2435,7 +2767,7 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f3a1a3341211875ef120e117ea7fd5228530ae7e7036a779fdc9117be6b3282c" dependencies = [ - "ordered-float", + "ordered-float 2.10.1", "serde", ] @@ -2456,27 +2788,27 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.114", ] [[package]] name = "serde_json" -version = "1.0.145" +version = "1.0.149" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" dependencies = [ "itoa", "memchr", - "ryu", "serde", "serde_core", + "zmij", ] [[package]] name = "serde_spanned" -version = "1.0.3" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e24345aa0fe688594e73770a5f6d1b216508b4f93484c0026d521acd30134392" +checksum = "f8bbf91e5a4d6315eee45e704372590b30e260ee83af6639d64557f51b067776" dependencies = [ "serde_core", ] @@ -2508,11 +2840,12 @@ dependencies = [ [[package]] name = "serial_test" -version = "3.2.0" +version = "3.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b258109f244e1d6891bf1053a55d63a5cd4f8f4c30cf9a1280989f80e7a1fa9" +checksum = "0d0b343e184fc3b7bb44dff0705fffcf4b3756ba6aff420dddd8b24ca145e555" dependencies = [ - "futures", + "futures-executor", + "futures-util", "log", "once_cell", "parking_lot", @@ -2522,13 +2855,24 @@ dependencies = [ [[package]] name = "serial_test_derive" -version = "3.2.0" +version = "3.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d69265a08751de7844521fd15003ae0a888e035773ba05695c5c759a6f89eef" +checksum = "6f50427f258fb77356e4cd4aa0e87e2bd2c66dbcee41dc405282cae2bfc26c83" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.114", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", ] [[package]] @@ -2560,10 +2904,11 @@ dependencies = [ [[package]] name = "signal-hook-registry" -version = "1.4.7" +version = "1.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7664a098b8e616bdfcc2dc0e9ac44eb231eedf41db4e9fe95d8d32ec728dedad" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" dependencies = [ + "errno", "libc", ] @@ -2573,6 +2918,12 @@ version = "2.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" +[[package]] +name = "siphasher" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56199f7ddabf13fe5074ce809e7d3f42b42ae711800501b5b16ea82ad029c39d" + [[package]] name = "slab" version = "0.4.11" @@ -2619,7 +2970,16 @@ version = "0.26.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" dependencies = [ - "strum_macros", + "strum_macros 0.26.4", +] + +[[package]] +name = "strum" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" +dependencies = [ + "strum_macros 0.27.2", ] [[package]] @@ -2632,7 +2992,19 @@ dependencies = [ "proc-macro2", "quote", "rustversion", - "syn 2.0.111", + "syn 2.0.114", +] + +[[package]] +name = "strum_macros" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.114", ] [[package]] @@ -2654,9 +3026,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.111" +version = "2.0.114" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "390cc9a294ab71bdb1aa2e99d13be9c753cd2d7bd6560c77118597410c4d2e87" +checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a" dependencies = [ "proc-macro2", "quote", @@ -2680,7 +3052,21 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.114", +] + +[[package]] +name = "sysinfo" +version = "0.37.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16607d5caffd1c07ce073528f9ed972d88db15dd44023fa57142963be3feb11f" +dependencies = [ + "libc", + "memchr", + "ntapi", + "objc2-core-foundation", + "objc2-io-kit", + "windows", ] [[package]] @@ -2689,7 +3075,7 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" dependencies = [ - "bitflags", + "bitflags 2.10.0", "core-foundation", "system-configuration-sys", ] @@ -2706,14 +3092,14 @@ dependencies = [ [[package]] name = "tempfile" -version = "3.23.0" +version = "3.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d31c77bdf42a745371d260a26ca7163f1e0924b64afa0b688e61b5a9fa02f16" +checksum = "655da9c7eb6305c55742045d5a8d2037996d61d8de95806335c7c86ce0f82e9c" dependencies = [ "fastrand", "getrandom 0.3.4", "once_cell", - "rustix 1.1.2", + "rustix 1.1.3", "windows-sys 0.61.2", ] @@ -2723,16 +3109,79 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "60b8cb979cb11c32ce1603f8137b22262a9d131aaa5c37b5678025f22b8becd0" dependencies = [ - "rustix 1.1.2", + "rustix 1.1.3", "windows-sys 0.60.2", ] +[[package]] +name = "terminfo" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4ea810f0692f9f51b382fff5893887bb4580f5fa246fde546e0b13e7fcee662" +dependencies = [ + "fnv", + "nom", + "phf", + "phf_codegen", +] + +[[package]] +name = "termios" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "411c5bf740737c7918b8b1fe232dca4dc9f8e754b8ad5e20966814001ed0ac6b" +dependencies = [ + "libc", +] + [[package]] name = "termtree" version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683" +[[package]] +name = "termwiz" +version = "0.23.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4676b37242ccbd1aabf56edb093a4827dc49086c0ffd764a5705899e0f35f8f7" +dependencies = [ + "anyhow", + "base64", + "bitflags 2.10.0", + "fancy-regex", + "filedescriptor", + "finl_unicode", + "fixedbitset", + "hex", + "lazy_static", + "libc", + "log", + "memmem", + "nix 0.29.0", + "num-derive", + "num-traits", + "ordered-float 4.6.0", + "pest", + "pest_derive", + "phf", + "sha2", + "signal-hook", + "siphasher", + "terminfo", + "termios", + "thiserror 1.0.69", + "ucd-trie", + "unicode-segmentation", + "vtparse", + "wezterm-bidi", + "wezterm-blob-leases", + "wezterm-color-types", + "wezterm-dynamic", + "wezterm-input-types", + "winapi", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -2759,7 +3208,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.114", ] [[package]] @@ -2770,7 +3219,7 @@ checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.114", ] [[package]] @@ -2816,9 +3265,9 @@ dependencies = [ [[package]] name = "tokio" -version = "1.48.0" +version = "1.49.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff360e02eab121e0bc37a2d3b4d4dc622e6eda3a8e5253d5435ecf5bd4c68408" +checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86" dependencies = [ "bytes", "libc", @@ -2839,7 +3288,7 @@ checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.114", ] [[package]] @@ -2864,9 +3313,9 @@ dependencies = [ [[package]] name = "tokio-util" -version = "0.7.17" +version = "0.7.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2efa149fe76073d6e8fd97ef4f4eca7b67f599660115591483572e406e165594" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" dependencies = [ "bytes", "futures-core", @@ -2877,9 +3326,9 @@ dependencies = [ [[package]] name = "toml" -version = "0.9.8" +version = "0.9.10+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0dc8b1fb61449e27716ec0e1bdf0f6b8f3e8f6b05391e8497b8b6d7804ea6d8" +checksum = "0825052159284a1a8b4d6c0c86cbc801f2da5afd2b225fa548c72f2e74002f48" dependencies = [ "serde_core", "serde_spanned", @@ -2889,18 +3338,18 @@ dependencies = [ [[package]] name = "toml_datetime" -version = "0.7.3" +version = "0.7.5+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2cdb639ebbc97961c51720f858597f7f24c4fc295327923af55b74c3c724533" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" dependencies = [ "serde_core", ] [[package]] name = "toml_edit" -version = "0.23.9" +version = "0.23.10+spec-1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d7cbc3b4b49633d57a0509303158ca50de80ae32c265093b24c414705807832" +checksum = "84c8b9f757e028cee9fa244aea147aab2a9ec09d5325a9b01e0a49730c2b5269" dependencies = [ "indexmap", "toml_datetime", @@ -2910,18 +3359,18 @@ dependencies = [ [[package]] name = "toml_parser" -version = "1.0.4" +version = "1.0.6+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0cbe268d35bdb4bb5a56a2de88d0ad0eb70af5384a99d648cd4b3d04039800e" +checksum = "a3198b4b0a8e11f09dd03e133c0280504d0801269e9afa46362ffde1cbeebf44" dependencies = [ "winnow", ] [[package]] name = "toml_writer" -version = "1.0.4" +version = "1.0.6+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df8b2b54733674ad286d16267dcfc7a71ed5c776e4ac7aa3c3e2561f7c637bf2" +checksum = "ab16f14aed21ee8bfd8ec22513f7287cd4a91aa92e44edfe2c17ddd004e92607" [[package]] name = "tower" @@ -2944,7 +3393,7 @@ version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" dependencies = [ - "bitflags", + "bitflags 2.10.0", "bytes", "futures-util", "http", @@ -2970,9 +3419,9 @@ checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" [[package]] name = "tracing" -version = "0.1.43" +version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d15d90a0b5c19378952d479dc858407149d7bb45a14de0142f6c534b16fc647" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ "pin-project-lite", "tracing-core", @@ -2980,9 +3429,9 @@ dependencies = [ [[package]] name = "tracing-core" -version = "0.1.35" +version = "0.1.36" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a04e24fab5c89c6a36eb8558c9656f30d81de51dfa4d3b45f26b21d61fa0a6c" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" dependencies = [ "once_cell", ] @@ -3002,6 +3451,18 @@ dependencies = [ "unsafe-any-ors", ] +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + [[package]] name = "unarray" version = "0.1.4" @@ -3022,21 +3483,15 @@ checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" [[package]] name = "unicode-truncate" -version = "1.1.0" +version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" +checksum = "8fbf03860ff438702f3910ca5f28f8dac63c1c11e7efb5012b8b175493606330" dependencies = [ "itertools 0.13.0", "unicode-segmentation", - "unicode-width 0.1.14", + "unicode-width", ] -[[package]] -name = "unicode-width" -version = "0.1.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" - [[package]] name = "unicode-width" version = "0.2.0" @@ -3072,9 +3527,9 @@ checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" [[package]] name = "url" -version = "2.5.7" +version = "2.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08bc136a29a3d1758e07a9cca267be308aeebf5cfd5a10f3f67ab2097683ef5b" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" dependencies = [ "form_urlencoded", "idna", @@ -3106,7 +3561,10 @@ version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e2e054861b4bd027cd373e18e8d8d8e6548085000e41290d95ce0c373a654b4a" dependencies = [ + "atomic", "getrandom 0.3.4", + "js-sys", + "wasm-bindgen", ] [[package]] @@ -3116,7 +3574,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40630259c022600210096da9538abcb992b801e30b464cb9d19f19ef0e0d09b9" dependencies = [ "quote", - "syn 2.0.111", + "syn 2.0.114", ] [[package]] @@ -3125,7 +3583,7 @@ version = "0.6.1" dependencies = [ "log", "quote", - "syn 2.0.111", + "syn 2.0.114", ] [[package]] @@ -3152,7 +3610,22 @@ checksum = "5b2d5567b6fbd34e8f0488d56b648e67c0d999535f4af2060d14f9074b43e833" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.114", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vtparse" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d9b2acfb050df409c972a37d3b8e08cdea3bddb0c09db9d53137e504cfabed0" +dependencies = [ + "utf8parse", ] [[package]] @@ -3233,7 +3706,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.114", "wasm-bindgen-shared", ] @@ -3266,6 +3739,78 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "wezterm-bidi" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0a6e355560527dd2d1cf7890652f4f09bb3433b6aadade4c9b5ed76de5f3ec" +dependencies = [ + "log", + "wezterm-dynamic", +] + +[[package]] +name = "wezterm-blob-leases" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "692daff6d93d94e29e4114544ef6d5c942a7ed998b37abdc19b17136ea428eb7" +dependencies = [ + "getrandom 0.3.4", + "mac_address", + "sha2", + "thiserror 1.0.69", + "uuid", +] + +[[package]] +name = "wezterm-color-types" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7de81ef35c9010270d63772bebef2f2d6d1f2d20a983d27505ac850b8c4b4296" +dependencies = [ + "csscolorparser", + "deltae", + "lazy_static", + "wezterm-dynamic", +] + +[[package]] +name = "wezterm-dynamic" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f2ab60e120fd6eaa68d9567f3226e876684639d22a4219b313ff69ec0ccd5ac" +dependencies = [ + "log", + "ordered-float 4.6.0", + "strsim", + "thiserror 1.0.69", + "wezterm-dynamic-derive", +] + +[[package]] +name = "wezterm-dynamic-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46c0cf2d539c645b448eaffec9ec494b8b19bd5077d9e58cb1ae7efece8d575b" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "wezterm-input-types" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7012add459f951456ec9d6c7e6fc340b1ce15d6fc9629f8c42853412c029e57e" +dependencies = [ + "bitflags 1.3.2", + "euclid", + "lazy_static", + "serde", + "wezterm-dynamic", +] + [[package]] name = "winapi" version = "0.3.9" @@ -3288,6 +3833,41 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "windows" +version = "0.61.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" +dependencies = [ + "windows-collections", + "windows-core 0.61.2", + "windows-future", + "windows-link 0.1.3", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" +dependencies = [ + "windows-core 0.61.2", +] + +[[package]] +name = "windows-core" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.1.3", + "windows-result 0.3.4", + "windows-strings 0.4.2", +] + [[package]] name = "windows-core" version = "0.62.2" @@ -3296,9 +3876,20 @@ checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" dependencies = [ "windows-implement", "windows-interface", - "windows-link", - "windows-result", - "windows-strings", + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + +[[package]] +name = "windows-future" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", + "windows-threading", ] [[package]] @@ -3309,7 +3900,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.114", ] [[package]] @@ -3320,24 +3911,49 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.114", ] +[[package]] +name = "windows-link" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-numerics" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", +] + [[package]] name = "windows-registry" version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" dependencies = [ - "windows-link", - "windows-result", - "windows-strings", + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + +[[package]] +name = "windows-result" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +dependencies = [ + "windows-link 0.1.3", ] [[package]] @@ -3346,7 +3962,16 @@ version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" dependencies = [ - "windows-link", + "windows-link 0.2.1", +] + +[[package]] +name = "windows-strings" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +dependencies = [ + "windows-link 0.1.3", ] [[package]] @@ -3355,7 +3980,7 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" dependencies = [ - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -3400,7 +4025,7 @@ version = "0.61.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" dependencies = [ - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -3440,7 +4065,7 @@ version = "0.53.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" dependencies = [ - "windows-link", + "windows-link 0.2.1", "windows_aarch64_gnullvm 0.53.1", "windows_aarch64_msvc 0.53.1", "windows_i686_gnu 0.53.1", @@ -3451,6 +4076,15 @@ dependencies = [ "windows_x86_64_msvc 0.53.1", ] +[[package]] +name = "windows-threading" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" +dependencies = [ + "windows-link 0.1.3", +] + [[package]] name = "windows_aarch64_gnullvm" version = "0.48.5" @@ -3635,28 +4269,28 @@ checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.114", "synstructure", ] [[package]] name = "zerocopy" -version = "0.8.31" +version = "0.8.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd74ec98b9250adb3ca554bdde269adf631549f51d8a8f8f0a10b50f1cb298c3" +checksum = "668f5168d10b9ee831de31933dc111a459c97ec93225beb307aed970d1372dfd" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.31" +version = "0.8.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8a8d209fdf45cf5138cbb5a506f6b52522a25afccc534d1475dad8e31105c6a" +checksum = "2c7962b26b0a8685668b671ee4b54d007a67d4eaf05fda79ac0ecf41e32270f1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.114", ] [[package]] @@ -3676,7 +4310,7 @@ checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.114", "synstructure", ] @@ -3716,5 +4350,11 @@ checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.114", ] + +[[package]] +name = "zmij" +version = "1.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fc5a66a20078bf1251bde995aa2fdcc4b800c70b5d92dd2c62abc5c60f679f8" diff --git a/Cargo.toml b/Cargo.toml index 8f4aea4..f83eb0d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -42,7 +42,7 @@ strum = { version = "0.26.3", features = ["derive"] } strum_macros = "0.26.4" tokio = { version = "1.44.2", features = ["full"] } tokio-util = "0.7.8" -ratatui = { version = "0.29.0", features = [ +ratatui = { version = "0.30.0", features = [ "all-widgets", "unstable-widget-ref", ] } @@ -59,7 +59,7 @@ ctrlc = "3.4.5" colored = "3.0.0" async-trait = "0.1.83" dirs-next = "2.0.0" -managarr-tree-widget = "0.24.0" +managarr-tree-widget = "0.25.0" indicatif = "0.17.9" derive_setters = "0.1.6" deunicode = "1.6.0" diff --git a/src/ui/lidarr_ui/library/add_artist_ui.rs b/src/ui/lidarr_ui/library/add_artist_ui.rs index 497a98c..e10c891 100644 --- a/src/ui/lidarr_ui/library/add_artist_ui.rs +++ b/src/ui/lidarr_ui/library/add_artist_ui.rs @@ -109,14 +109,14 @@ fn draw_add_artist_search(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { .block(title_block_centered("Add Artist")); search_box.show_cursor(f, search_box_area); - f.render_widget(layout_block().default(), results_area); + f.render_widget(layout_block().default_color(), results_area); f.render_widget(search_box, search_box_area); } ActiveLidarrBlock::AddArtistEmptySearchResults => { let error_message = Message::new("No artists found matching your query!"); let error_message_popup = Popup::new(error_message).size(Size::Message); - f.render_widget(layout_block().default(), results_area); + f.render_widget(layout_block().default_color(), results_area); f.render_widget(error_message_popup, f.area()); } ActiveLidarrBlock::AddArtistSearchResults => { @@ -125,7 +125,7 @@ fn draw_add_artist_search(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { search_results_row_mapping, ) .loading(is_loading) - .block(layout_block().default()) + .block(layout_block().default_color()) .headers(["✔", "Name", "Type", "Status", "Rating", "Genres"]) .constraints([ Constraint::Percentage(3), diff --git a/src/ui/lidarr_ui/library/edit_artist_ui.rs b/src/ui/lidarr_ui/library/edit_artist_ui.rs index 9a45554..6c0e656 100644 --- a/src/ui/lidarr_ui/library/edit_artist_ui.rs +++ b/src/ui/lidarr_ui/library/edit_artist_ui.rs @@ -11,7 +11,6 @@ use crate::models::servarr_data::lidarr::lidarr_data::{ActiveLidarrBlock, EDIT_A use crate::models::servarr_data::lidarr::modals::EditArtistModal; use crate::render_selectable_input_box; -use crate::ui::styles::ManagarrStyle; use crate::ui::utils::title_block_centered; use crate::ui::widgets::button::Button; use crate::ui::widgets::checkbox::Checkbox; @@ -120,17 +119,17 @@ fn draw_edit_artist_confirmation_prompt(f: &mut Frame<'_>, app: &mut App<'_>, ar let monitored_checkbox = Checkbox::new("Monitored") .checked(monitored.unwrap_or_default()) .highlighted(selected_block == ActiveLidarrBlock::EditArtistToggleMonitored); - let monitor_new_items_drop_down_button = Button::new() + let monitor_new_items_drop_down_button = Button::default() .title(selected_monitor_new_items.to_display_str()) .label("Monitor New Albums") .icon("▼") .selected(selected_block == ActiveLidarrBlock::EditArtistSelectMonitorNewItems); - let quality_profile_drop_down_button = Button::new() + let quality_profile_drop_down_button = Button::default() .title(selected_quality_profile) .label("Quality Profile") .icon("▼") .selected(selected_block == ActiveLidarrBlock::EditArtistSelectQualityProfile); - let metadata_profile_drop_down_button = Button::new() + let metadata_profile_drop_down_button = Button::default() .title(selected_metadata_profile) .label("Metadata Profile") .icon("▼") @@ -158,10 +157,10 @@ fn draw_edit_artist_confirmation_prompt(f: &mut Frame<'_>, app: &mut App<'_>, ar render_selectable_input_box!(tags_input_box, f, tags_area); } - let save_button = Button::new() + let save_button = Button::default() .title("Save") .selected(yes_no_value && highlight_yes_no); - let cancel_button = Button::new() + let cancel_button = Button::default() .title("Cancel") .selected(!yes_no_value && highlight_yes_no); diff --git a/src/ui/lidarr_ui/mod.rs b/src/ui/lidarr_ui/mod.rs index a5df07c..0bb8614 100644 --- a/src/ui/lidarr_ui/mod.rs +++ b/src/ui/lidarr_ui/mod.rs @@ -154,7 +154,7 @@ fn draw_stats_context(f: &mut Frame<'_>, app: &App<'_>, area: Rect) { let space: f64 = convert_to_gb(*free_space); let root_folder_space = Paragraph::new(format!("{path}: {space:.2} GB free")) .block(borderless_block()) - .default(); + .default_color(); f.render_widget( root_folder_space, @@ -205,7 +205,7 @@ fn draw_lidarr_logo(f: &mut Frame<'_>, area: Rect) { let logo_text = Text::from(LIDARR_LOGO); let logo = Paragraph::new(logo_text) .light_green() - .block(layout_block().default()) + .block(layout_block().default_color()) .centered(); f.render_widget(logo, area); } diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 1bff146..9842a5f 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -4,7 +4,7 @@ use std::sync::atomic::Ordering; use lidarr_ui::LidarrUi; use ratatui::Frame; use ratatui::layout::{Constraint, Flex, Layout, Rect}; -use ratatui::style::{Style, Stylize}; +use ratatui::style::Stylize; use ratatui::text::{Line, Text}; use ratatui::widgets::Paragraph; use ratatui::widgets::Tabs; @@ -17,7 +17,7 @@ use crate::app::App; use crate::models::servarr_models::KeybindingItem; use crate::models::{HorizontallyScrollableText, Route, TabState}; use crate::ui::radarr_ui::RadarrUi; -use crate::ui::styles::ManagarrStyle; +use crate::ui::styles::{ManagarrStyle, secondary_style}; use crate::ui::theme::Theme; use crate::ui::utils::{ background_block, borderless_block, centered_rect, logo_block, title_block, title_block_centered, @@ -116,7 +116,7 @@ fn draw_header_row(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { .map(|tab| Line::from(tab.title.clone().bold())); let tabs = Tabs::new(titles) .block(borderless_block()) - .highlight_style(Style::new().secondary()) + .highlight_style(secondary_style()) .select(app.server_tabs.index); let help = Paragraph::new(help_text) .block(borderless_block()) @@ -185,7 +185,7 @@ pub fn draw_help_popup(f: &mut Frame<'_>, app: &mut App<'_>) { fn draw_tabs(f: &mut Frame<'_>, area: Rect, title: &str, tab_state: &TabState) -> Rect { if title.is_empty() { - f.render_widget(layout_block().default(), area); + f.render_widget(layout_block().default_color(), area); } else { f.render_widget(title_block(title), area); } @@ -200,7 +200,7 @@ fn draw_tabs(f: &mut Frame<'_>, area: Rect, title: &str, tab_state: &TabState) - .map(|tab_route| Line::from(tab_route.title.clone().bold())); let tabs = Tabs::new(titles) .block(borderless_block()) - .highlight_style(Style::new().secondary()) + .highlight_style(secondary_style()) .select(tab_state.index); f.render_widget(tabs, header_area); diff --git a/src/ui/radarr_ui/blocklist/mod.rs b/src/ui/radarr_ui/blocklist/mod.rs index 4301221..3fb243b 100644 --- a/src/ui/radarr_ui/blocklist/mod.rs +++ b/src/ui/radarr_ui/blocklist/mod.rs @@ -3,7 +3,7 @@ use crate::models::Route; use crate::models::radarr_models::BlocklistItem; use crate::models::servarr_data::radarr::radarr_data::{ActiveRadarrBlock, BLOCKLIST_BLOCKS}; use crate::ui::DrawUi; -use crate::ui::styles::ManagarrStyle; +use crate::ui::styles::{ManagarrStyle, secondary_style}; use crate::ui::utils::{get_width_from_percentage, layout_block_top_border}; use crate::ui::widgets::confirmation_prompt::ConfirmationPrompt; use crate::ui::widgets::managarr_table::ManagarrTable; @@ -11,7 +11,7 @@ use crate::ui::widgets::message::Message; use crate::ui::widgets::popup::{Popup, Size}; use ratatui::Frame; use ratatui::layout::{Alignment, Constraint, Rect}; -use ratatui::style::{Style, Stylize}; +use ratatui::style::Stylize; use ratatui::text::{Line, Text}; use ratatui::widgets::{Cell, Row}; @@ -186,7 +186,7 @@ fn draw_blocklist_item_details_popup(f: &mut Frame<'_>, app: &mut App<'_>) { let message = Message::new(text) .title("Details") - .style(Style::new().secondary()) + .style(secondary_style()) .alignment(Alignment::Left); f.render_widget(Popup::new(message).size(Size::NarrowMessage), f.area()); diff --git a/src/ui/radarr_ui/collections/collection_details_ui.rs b/src/ui/radarr_ui/collections/collection_details_ui.rs index 9567523..3daeaa4 100644 --- a/src/ui/radarr_ui/collections/collection_details_ui.rs +++ b/src/ui/radarr_ui/collections/collection_details_ui.rs @@ -150,7 +150,7 @@ pub fn draw_collection_details(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) .overview .clone() .unwrap_or_default() - .default(), + .default_color(), ]), Line::from(vec![ "Root Folder Path: ".primary().bold(), @@ -158,20 +158,23 @@ pub fn draw_collection_details(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) .root_folder_path .clone() .unwrap_or_default() - .default(), + .default_color(), ]), Line::from(vec![ "Quality Profile: ".primary().bold(), - quality_profile.default(), + quality_profile.default_color(), ]), Line::from(vec![ "Minimum Availability: ".primary().bold(), - minimum_availability.default(), + minimum_availability.default_color(), + ]), + Line::from(vec![ + "Monitored: ".primary().bold(), + monitored.default_color(), ]), - Line::from(vec!["Monitored: ".primary().bold(), monitored.default()]), Line::from(vec![ "Search on Add: ".primary().bold(), - search_on_add.default(), + search_on_add.default_color(), ]), ]); @@ -225,7 +228,7 @@ fn draw_movie_overview(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { .clone() .overview, ) - .default(); + .default_color(); let paragraph = Paragraph::new(overview) .block(borderless_block()) diff --git a/src/ui/radarr_ui/collections/edit_collection_ui.rs b/src/ui/radarr_ui/collections/edit_collection_ui.rs index 38ef03c..f6702fa 100644 --- a/src/ui/radarr_ui/collections/edit_collection_ui.rs +++ b/src/ui/radarr_ui/collections/edit_collection_ui.rs @@ -11,7 +11,6 @@ use crate::models::servarr_data::radarr::radarr_data::{ }; use crate::render_selectable_input_box; use crate::ui::radarr_ui::collections::collection_details_ui::CollectionDetailsUi; -use crate::ui::styles::ManagarrStyle; use crate::ui::utils::{layout_paragraph_borderless, title_block_centered}; use crate::ui::widgets::button::Button; use crate::ui::widgets::checkbox::Checkbox; @@ -129,12 +128,12 @@ fn draw_edit_collection_confirmation_prompt(f: &mut Frame<'_>, app: &mut App<'_> let monitored_checkbox = Checkbox::new("Monitored") .highlighted(selected_block == ActiveRadarrBlock::EditCollectionToggleMonitored) .checked(monitored.unwrap_or_default()); - let min_availability_drop_down_button = Button::new() + let min_availability_drop_down_button = Button::default() .title(selected_minimum_availability.to_display_str()) .label("Minimum Availability") .icon("▼") .selected(selected_block == ActiveRadarrBlock::EditCollectionSelectMinimumAvailability); - let quality_profile_drop_down_button = Button::new() + let quality_profile_drop_down_button = Button::default() .title(selected_quality_profile) .label("Quality Profile") .icon("▼") @@ -152,10 +151,10 @@ fn draw_edit_collection_confirmation_prompt(f: &mut Frame<'_>, app: &mut App<'_> let search_on_add_checkbox = Checkbox::new("Search on Add") .highlighted(selected_block == ActiveRadarrBlock::EditCollectionToggleSearchOnAdd) .checked(search_on_add.unwrap_or_default()); - let save_button = Button::new() + let save_button = Button::default() .title("Save") .selected(yes_no_value && highlight_yes_no); - let cancel_button = Button::new() + let cancel_button = Button::default() .title("Cancel") .selected(!yes_no_value && highlight_yes_no); diff --git a/src/ui/radarr_ui/indexers/edit_indexer_ui.rs b/src/ui/radarr_ui/indexers/edit_indexer_ui.rs index cd7f52a..1a98be2 100644 --- a/src/ui/radarr_ui/indexers/edit_indexer_ui.rs +++ b/src/ui/radarr_ui/indexers/edit_indexer_ui.rs @@ -4,7 +4,6 @@ use crate::app::App; use crate::models::Route; use crate::models::servarr_data::radarr::radarr_data::{ActiveRadarrBlock, EDIT_INDEXER_BLOCKS}; use crate::render_selectable_input_box; -use crate::ui::styles::ManagarrStyle; use crate::ui::utils::title_block_centered; use crate::ui::widgets::button::Button; use crate::ui::widgets::checkbox::Checkbox; @@ -152,10 +151,10 @@ fn draw_edit_indexer_prompt(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { .flex(Flex::Center) .areas(buttons_area); - let save_button = Button::new() + let save_button = Button::default() .title("Save") .selected(yes_no_value && highlight_yes_no); - let cancel_button = Button::new() + let cancel_button = Button::default() .title("Cancel") .selected(!yes_no_value && highlight_yes_no); diff --git a/src/ui/radarr_ui/indexers/indexer_settings_ui.rs b/src/ui/radarr_ui/indexers/indexer_settings_ui.rs index 677f17f..842b440 100644 --- a/src/ui/radarr_ui/indexers/indexer_settings_ui.rs +++ b/src/ui/radarr_ui/indexers/indexer_settings_ui.rs @@ -9,7 +9,6 @@ use crate::models::servarr_data::radarr::radarr_data::{ ActiveRadarrBlock, INDEXER_SETTINGS_BLOCKS, }; use crate::render_selectable_input_box; -use crate::ui::styles::ManagarrStyle; use crate::ui::utils::title_block_centered; use crate::ui::widgets::button::Button; use crate::ui::widgets::checkbox::Checkbox; @@ -154,10 +153,10 @@ fn draw_edit_indexer_settings_prompt(f: &mut Frame<'_>, app: &mut App<'_>, area: .flex(Flex::Center) .areas(buttons_area); - let save_button = Button::new() + let save_button = Button::default() .title("Save") .selected(yes_no_value && highlight_yes_no); - let cancel_button = Button::new() + let cancel_button = Button::default() .title("Cancel") .selected(!yes_no_value && highlight_yes_no); diff --git a/src/ui/radarr_ui/indexers/mod.rs b/src/ui/radarr_ui/indexers/mod.rs index bc63c90..3f9cee1 100644 --- a/src/ui/radarr_ui/indexers/mod.rs +++ b/src/ui/radarr_ui/indexers/mod.rs @@ -1,6 +1,6 @@ +use crate::ui::styles::success_style; use ratatui::Frame; use ratatui::layout::{Constraint, Rect}; -use ratatui::style::{Style, Stylize}; use ratatui::text::Text; use ratatui::widgets::{Cell, Row}; @@ -73,7 +73,7 @@ impl DrawUi for IndexersUi { } else { let message = Message::new("Indexer test succeeded!") .title("Success") - .style(Style::new().success().bold()); + .style(success_style().bold()); Popup::new(message).size(Size::Message) } }; diff --git a/src/ui/radarr_ui/library/add_movie_ui.rs b/src/ui/radarr_ui/library/add_movie_ui.rs index ee2becd..f3afa39 100644 --- a/src/ui/radarr_ui/library/add_movie_ui.rs +++ b/src/ui/radarr_ui/library/add_movie_ui.rs @@ -164,14 +164,14 @@ fn draw_add_movie_search(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { .block(title_block_centered("Add Movie")); search_box.show_cursor(f, search_box_area); - f.render_widget(layout_block().default(), results_area); + f.render_widget(layout_block().default_color(), results_area); f.render_widget(search_box, search_box_area); } ActiveRadarrBlock::AddMovieEmptySearchResults => { let error_message = Message::new("No movies found matching your query!"); let error_message_popup = Popup::new(error_message).size(Size::Message); - f.render_widget(layout_block().default(), results_area); + f.render_widget(layout_block().default_color(), results_area); f.render_widget(error_message_popup, f.area()); } ActiveRadarrBlock::AddMovieSearchResults @@ -187,7 +187,7 @@ fn draw_add_movie_search(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { search_results_row_mapping, ) .loading(is_loading) - .block(layout_block().default()) + .block(layout_block().default_color()) .headers([ "✔", "Title", @@ -338,22 +338,22 @@ fn draw_confirmation_prompt(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { Layout::horizontal([Constraint::Percentage(50), Constraint::Percentage(50)]) .areas(buttons_area); - let root_folder_drop_down_button = Button::new() + let root_folder_drop_down_button = Button::default() .title(&selected_root_folder.path) .label("Root Folder") .icon("▼") .selected(selected_block == ActiveRadarrBlock::AddMovieSelectRootFolder); - let monitor_drop_down_button = Button::new() + let monitor_drop_down_button = Button::default() .title(selected_monitor.to_display_str()) .label("Monitor") .icon("▼") .selected(selected_block == ActiveRadarrBlock::AddMovieSelectMonitor); - let min_availability_drop_down_button = Button::new() + let min_availability_drop_down_button = Button::default() .title(selected_minimum_availability.to_display_str()) .label("Minimum Availability") .icon("▼") .selected(selected_block == ActiveRadarrBlock::AddMovieSelectMinimumAvailability); - let quality_profile_drop_down_button = Button::new() + let quality_profile_drop_down_button = Button::default() .title(selected_quality_profile) .label("Quality Profile") .icon("▼") @@ -373,10 +373,10 @@ fn draw_confirmation_prompt(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { render_selectable_input_box!(tags_input_box, f, tags_area); } - let add_button = Button::new() + let add_button = Button::default() .title("Add") .selected(yes_no_value && highlight_yes_no); - let cancel_button = Button::new() + let cancel_button = Button::default() .title("Cancel") .selected(!yes_no_value && highlight_yes_no); diff --git a/src/ui/radarr_ui/library/edit_movie_ui.rs b/src/ui/radarr_ui/library/edit_movie_ui.rs index f944e81..e759a8a 100644 --- a/src/ui/radarr_ui/library/edit_movie_ui.rs +++ b/src/ui/radarr_ui/library/edit_movie_ui.rs @@ -14,7 +14,6 @@ use crate::models::servarr_data::radarr::radarr_data::{ use crate::render_selectable_input_box; use crate::ui::radarr_ui::library::movie_details_ui::MovieDetailsUi; -use crate::ui::styles::ManagarrStyle; use crate::ui::utils::{layout_paragraph_borderless, title_block_centered}; use crate::ui::widgets::button::Button; use crate::ui::widgets::checkbox::Checkbox; @@ -125,12 +124,12 @@ fn draw_edit_movie_confirmation_prompt(f: &mut Frame<'_>, app: &mut App<'_>, are let monitored_checkbox = Checkbox::new("Monitored") .checked(monitored.unwrap_or_default()) .highlighted(selected_block == ActiveRadarrBlock::EditMovieToggleMonitored); - let min_availability_drop_down_button = Button::new() + let min_availability_drop_down_button = Button::default() .title(selected_minimum_availability.to_display_str()) .label("Minimum Availability") .icon("▼") .selected(selected_block == ActiveRadarrBlock::EditMovieSelectMinimumAvailability); - let quality_profile_drop_down_button = Button::new() + let quality_profile_drop_down_button = Button::default() .title(selected_quality_profile) .label("Quality Profile") .icon("▼") @@ -158,10 +157,10 @@ fn draw_edit_movie_confirmation_prompt(f: &mut Frame<'_>, app: &mut App<'_>, are render_selectable_input_box!(tags_input_box, f, tags_area); } - let save_button = Button::new() + let save_button = Button::default() .title("Save") .selected(yes_no_value && highlight_yes_no); - let cancel_button = Button::new() + let cancel_button = Button::default() .title("Cancel") .selected(!yes_no_value && highlight_yes_no); diff --git a/src/ui/radarr_ui/library/movie_details_ui.rs b/src/ui/radarr_ui/library/movie_details_ui.rs index 7823d2b..e36ecad 100644 --- a/src/ui/radarr_ui/library/movie_details_ui.rs +++ b/src/ui/radarr_ui/library/movie_details_ui.rs @@ -1,3 +1,7 @@ +use crate::ui::styles::{ + awaiting_import_style, downloaded_style, downloading_style, missing_style, + unmonitored_missing_style, unreleased_style, +}; use std::iter; use ratatui::Frame; @@ -529,12 +533,12 @@ fn draw_manual_search_confirm_prompt(f: &mut Frame<'_>, app: &mut App<'_>) { fn style_from_download_status(download_status: &str, is_monitored: bool, status: String) -> Style { match download_status { - "Downloaded" => Style::new().downloaded(), - "Awaiting Import" => Style::new().awaiting_import(), - "Downloading" => Style::new().downloading(), - _ if !is_monitored && download_status == "Missing" => Style::new().unmonitored_missing(), - _ if status != "released" && download_status == "Missing" => Style::new().unreleased(), - "Missing" => Style::new().missing(), - _ => Style::new().downloaded(), + "Downloaded" => downloaded_style(), + "Awaiting Import" => awaiting_import_style(), + "Downloading" => downloading_style(), + _ if !is_monitored && download_status == "Missing" => unmonitored_missing_style(), + _ if status != "released" && download_status == "Missing" => unreleased_style(), + "Missing" => missing_style(), + _ => downloaded_style(), } } diff --git a/src/ui/radarr_ui/library/movie_details_ui_tests.rs b/src/ui/radarr_ui/library/movie_details_ui_tests.rs index 46e8417..551297d 100644 --- a/src/ui/radarr_ui/library/movie_details_ui_tests.rs +++ b/src/ui/radarr_ui/library/movie_details_ui_tests.rs @@ -11,7 +11,10 @@ mod tests { use crate::ui::radarr_ui::library::movie_details_ui::{ MovieDetailsUi, style_from_download_status, }; - use crate::ui::styles::ManagarrStyle; + use crate::ui::styles::{ + awaiting_import_style, downloaded_style, downloading_style, missing_style, + unmonitored_missing_style, + }; use crate::ui::ui_test_utils::test_utils::{TerminalSize, render_to_string_with_app}; #[test] @@ -26,13 +29,13 @@ mod tests { } #[rstest] - #[case("Downloading", true, "", Style::new().downloading())] - #[case("Downloaded", true, "", Style::new().downloaded())] - #[case("Awaiting Import", true, "", Style::new().awaiting_import())] - #[case("Missing", false, "", Style::new().unmonitored_missing())] - #[case("Missing", false, "", Style::new().unmonitored_missing())] - #[case("Missing", true, "released", Style::new().missing())] - #[case("", true, "", Style::new().downloaded())] + #[case("Downloading", true, "", downloading_style())] + #[case("Downloaded", true, "", downloaded_style())] + #[case("Awaiting Import", true, "", awaiting_import_style())] + #[case("Missing", false, "", unmonitored_missing_style())] + #[case("Missing", false, "", unmonitored_missing_style())] + #[case("Missing", true, "released", missing_style())] + #[case("", true, "", downloaded_style())] fn test_style_from_download_status( #[case] download_status: &str, #[case] is_monitored: bool, diff --git a/src/ui/radarr_ui/mod.rs b/src/ui/radarr_ui/mod.rs index 991aa6e..8c6fbe6 100644 --- a/src/ui/radarr_ui/mod.rs +++ b/src/ui/radarr_ui/mod.rs @@ -161,7 +161,7 @@ fn draw_stats_context(f: &mut Frame<'_>, app: &App<'_>, area: Rect) { let space: f64 = convert_to_gb(*free_space); let root_folder_space = Paragraph::new(format!("{path}: {space:.2} GB free")) .block(borderless_block()) - .default(); + .default_color(); f.render_widget( root_folder_space, @@ -249,7 +249,7 @@ fn draw_radarr_logo(f: &mut Frame<'_>, area: Rect) { let logo_text = Text::from(RADARR_LOGO); let logo = Paragraph::new(logo_text) .light_yellow() - .block(layout_block().default()) + .block(layout_block().default_color()) .centered(); f.render_widget(logo, area); } diff --git a/src/ui/radarr_ui/system/mod.rs b/src/ui/radarr_ui/system/mod.rs index 2939924..79cf00c 100644 --- a/src/ui/radarr_ui/system/mod.rs +++ b/src/ui/radarr_ui/system/mod.rs @@ -1,3 +1,4 @@ +use crate::ui::styles::default_style; use std::ops::Sub; #[cfg(test)] @@ -5,7 +6,6 @@ use crate::ui::ui_test_utils::test_utils::Utc; #[cfg(not(test))] use chrono::Utc; use ratatui::layout::Layout; -use ratatui::style::Style; use ratatui::text::{Span, Text}; use ratatui::widgets::{Cell, Row}; use ratatui::{ @@ -178,7 +178,7 @@ fn draw_logs(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { style_log_list_item(ListItem::new(Text::from(Span::raw(log_line))), level) }) .block(block) - .highlight_style(Style::new().default()); + .highlight_style(default_style()); f.render_widget(logs_box, area); } diff --git a/src/ui/sonarr_ui/blocklist/mod.rs b/src/ui/sonarr_ui/blocklist/mod.rs index 6eb7f07..ea31dc2 100644 --- a/src/ui/sonarr_ui/blocklist/mod.rs +++ b/src/ui/sonarr_ui/blocklist/mod.rs @@ -3,7 +3,7 @@ use crate::models::Route; use crate::models::servarr_data::sonarr::sonarr_data::{ActiveSonarrBlock, BLOCKLIST_BLOCKS}; use crate::models::sonarr_models::BlocklistItem; use crate::ui::DrawUi; -use crate::ui::styles::ManagarrStyle; +use crate::ui::styles::{ManagarrStyle, secondary_style}; use crate::ui::utils::layout_block_top_border; use crate::ui::widgets::confirmation_prompt::ConfirmationPrompt; use crate::ui::widgets::managarr_table::ManagarrTable; @@ -11,7 +11,7 @@ use crate::ui::widgets::message::Message; use crate::ui::widgets::popup::{Popup, Size}; use ratatui::Frame; use ratatui::layout::{Alignment, Constraint, Rect}; -use ratatui::style::{Style, Stylize}; +use ratatui::style::Stylize; use ratatui::text::{Line, Text}; use ratatui::widgets::{Cell, Row}; @@ -163,7 +163,7 @@ fn draw_blocklist_item_details_popup(f: &mut Frame<'_>, app: &mut App<'_>) { let message = Message::new(text) .title("Details") - .style(Style::new().secondary()) + .style(secondary_style()) .alignment(Alignment::Left); f.render_widget(Popup::new(message).size(Size::NarrowMessage), f.area()); diff --git a/src/ui/sonarr_ui/history/mod.rs b/src/ui/sonarr_ui/history/mod.rs index 05c00ef..58e385e 100644 --- a/src/ui/sonarr_ui/history/mod.rs +++ b/src/ui/sonarr_ui/history/mod.rs @@ -4,14 +4,13 @@ use crate::models::servarr_data::sonarr::sonarr_data::{ActiveSonarrBlock, HISTOR use crate::models::servarr_models::Language; use crate::models::sonarr_models::{SonarrHistoryEventType, SonarrHistoryItem}; use crate::ui::DrawUi; -use crate::ui::styles::ManagarrStyle; +use crate::ui::styles::{ManagarrStyle, secondary_style}; use crate::ui::utils::{get_width_from_percentage, layout_block_top_border}; use crate::ui::widgets::managarr_table::ManagarrTable; use crate::ui::widgets::message::Message; use crate::ui::widgets::popup::{Popup, Size}; use ratatui::Frame; use ratatui::layout::{Alignment, Constraint, Rect}; -use ratatui::style::Style; use ratatui::text::Text; use ratatui::widgets::{Cell, Row}; @@ -151,7 +150,7 @@ fn draw_history_item_details_popup(f: &mut Frame<'_>, app: &mut App<'_>) { let message = Message::new(text) .title("Details") - .style(Style::new().secondary()) + .style(secondary_style()) .alignment(Alignment::Left); f.render_widget(Popup::new(message).size(Size::NarrowMessage), f.area()); diff --git a/src/ui/sonarr_ui/indexers/edit_indexer_ui.rs b/src/ui/sonarr_ui/indexers/edit_indexer_ui.rs index c12cafe..b0f9267 100644 --- a/src/ui/sonarr_ui/indexers/edit_indexer_ui.rs +++ b/src/ui/sonarr_ui/indexers/edit_indexer_ui.rs @@ -4,7 +4,6 @@ use crate::app::App; use crate::models::Route; use crate::models::servarr_data::sonarr::sonarr_data::{ActiveSonarrBlock, EDIT_INDEXER_BLOCKS}; use crate::render_selectable_input_box; -use crate::ui::styles::ManagarrStyle; use crate::ui::utils::title_block_centered; use crate::ui::widgets::button::Button; use crate::ui::widgets::checkbox::Checkbox; @@ -151,10 +150,10 @@ fn draw_edit_indexer_prompt(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { .flex(Flex::Center) .areas(buttons_area); - let save_button = Button::new() + let save_button = Button::default() .title("Save") .selected(yes_no_value && highlight_yes_no); - let cancel_button = Button::new() + let cancel_button = Button::default() .title("Cancel") .selected(!yes_no_value && highlight_yes_no); diff --git a/src/ui/sonarr_ui/indexers/indexer_settings_ui.rs b/src/ui/sonarr_ui/indexers/indexer_settings_ui.rs index 5dcdce9..6a77576 100644 --- a/src/ui/sonarr_ui/indexers/indexer_settings_ui.rs +++ b/src/ui/sonarr_ui/indexers/indexer_settings_ui.rs @@ -7,7 +7,6 @@ use crate::models::servarr_data::sonarr::sonarr_data::{ ActiveSonarrBlock, INDEXER_SETTINGS_BLOCKS, }; use crate::render_selectable_input_box; -use crate::ui::styles::ManagarrStyle; use crate::ui::utils::title_block_centered; use crate::ui::widgets::button::Button; use crate::ui::widgets::input_box::InputBox; @@ -103,10 +102,10 @@ fn draw_edit_indexer_settings_prompt(f: &mut Frame<'_>, app: &mut App<'_>, area: .flex(Flex::Center) .areas(buttons_area); - let save_button = Button::new() + let save_button = Button::default() .title("Save") .selected(yes_no_value && highlight_yes_no); - let cancel_button = Button::new() + let cancel_button = Button::default() .title("Cancel") .selected(!yes_no_value && highlight_yes_no); diff --git a/src/ui/sonarr_ui/indexers/mod.rs b/src/ui/sonarr_ui/indexers/mod.rs index 86b08b0..6dcda09 100644 --- a/src/ui/sonarr_ui/indexers/mod.rs +++ b/src/ui/sonarr_ui/indexers/mod.rs @@ -1,6 +1,6 @@ +use crate::ui::styles::success_style; use ratatui::Frame; use ratatui::layout::{Constraint, Rect}; -use ratatui::style::{Style, Stylize}; use ratatui::text::Text; use ratatui::widgets::{Cell, Row}; @@ -73,7 +73,7 @@ impl DrawUi for IndexersUi { } else { let message = Message::new("Indexer test succeeded!") .title("Success") - .style(Style::new().success().bold()); + .style(success_style().bold()); Popup::new(message).size(Size::Message) } }; diff --git a/src/ui/sonarr_ui/library/add_series_ui.rs b/src/ui/sonarr_ui/library/add_series_ui.rs index 3efdc9c..942cb8e 100644 --- a/src/ui/sonarr_ui/library/add_series_ui.rs +++ b/src/ui/sonarr_ui/library/add_series_ui.rs @@ -144,14 +144,14 @@ fn draw_add_series_search(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { .block(title_block_centered("Add Series")); search_box.show_cursor(f, search_box_area); - f.render_widget(layout_block().default(), results_area); + f.render_widget(layout_block().default_color(), results_area); f.render_widget(search_box, search_box_area); } ActiveSonarrBlock::AddSeriesEmptySearchResults => { let error_message = Message::new("No series found matching your query!"); let error_message_popup = Popup::new(error_message).size(Size::Message); - f.render_widget(layout_block().default(), results_area); + f.render_widget(layout_block().default_color(), results_area); f.render_widget(error_message_popup, f.area()); } ActiveSonarrBlock::AddSeriesSearchResults @@ -168,7 +168,7 @@ fn draw_add_series_search(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { search_results_row_mapping, ) .loading(is_loading) - .block(layout_block().default()) + .block(layout_block().default_color()) .headers([ "✔", "Title", "Year", "Network", "Seasons", "Rating", "Genres", ]) @@ -314,27 +314,27 @@ fn draw_confirmation_prompt(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { let use_season_folder_checkbox = Checkbox::new("Season Folder") .checked(*use_season_folder) .highlighted(selected_block == ActiveSonarrBlock::AddSeriesToggleUseSeasonFolder); - let root_folder_drop_down_button = Button::new() + let root_folder_drop_down_button = Button::default() .title(&selected_root_folder.path) .label("Root Folder") .icon("▼") .selected(selected_block == ActiveSonarrBlock::AddSeriesSelectRootFolder); - let monitor_drop_down_button = Button::new() + let monitor_drop_down_button = Button::default() .title(selected_monitor.to_display_str()) .label("Monitor") .icon("▼") .selected(selected_block == ActiveSonarrBlock::AddSeriesSelectMonitor); - let series_type_drop_down_button = Button::new() + let series_type_drop_down_button = Button::default() .title(selected_series_type.to_display_str()) .label("Series Type") .icon("▼") .selected(selected_block == ActiveSonarrBlock::AddSeriesSelectSeriesType); - let quality_profile_drop_down_button = Button::new() + let quality_profile_drop_down_button = Button::default() .title(selected_quality_profile) .label("Quality Profile") .icon("▼") .selected(selected_block == ActiveSonarrBlock::AddSeriesSelectQualityProfile); - let language_profile_drop_down_button = Button::new() + let language_profile_drop_down_button = Button::default() .title(selected_language_profile) .label("Language Profile") .icon("▼") @@ -356,10 +356,10 @@ fn draw_confirmation_prompt(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { render_selectable_input_box!(tags_input_box, f, tags_area); } - let add_button = Button::new() + let add_button = Button::default() .title("Add") .selected(yes_no_value && highlight_yes_no); - let cancel_button = Button::new() + let cancel_button = Button::default() .title("Cancel") .selected(!yes_no_value && highlight_yes_no); diff --git a/src/ui/sonarr_ui/library/edit_series_ui.rs b/src/ui/sonarr_ui/library/edit_series_ui.rs index 40c7f45..4e08a0a 100644 --- a/src/ui/sonarr_ui/library/edit_series_ui.rs +++ b/src/ui/sonarr_ui/library/edit_series_ui.rs @@ -13,7 +13,6 @@ use crate::models::servarr_data::sonarr::sonarr_data::{ }; use crate::render_selectable_input_box; -use crate::ui::styles::ManagarrStyle; use crate::ui::utils::{layout_paragraph_borderless, title_block_centered}; use crate::ui::widgets::button::Button; use crate::ui::widgets::checkbox::Checkbox; @@ -145,17 +144,17 @@ fn draw_edit_series_confirmation_prompt(f: &mut Frame<'_>, app: &mut App<'_>, ar let season_folder_checkbox = Checkbox::new("Season Folder") .checked(use_season_folders.unwrap_or_default()) .highlighted(selected_block == ActiveSonarrBlock::EditSeriesToggleSeasonFolder); - let series_type_drop_down_button = Button::new() + let series_type_drop_down_button = Button::default() .title(selected_series_type.to_display_str()) .label("Series Type") .icon("▼") .selected(selected_block == ActiveSonarrBlock::EditSeriesSelectSeriesType); - let quality_profile_drop_down_button = Button::new() + let quality_profile_drop_down_button = Button::default() .title(selected_quality_profile) .label("Quality Profile") .icon("▼") .selected(selected_block == ActiveSonarrBlock::EditSeriesSelectQualityProfile); - let language_profile_drop_down_button = Button::new() + let language_profile_drop_down_button = Button::default() .title(selected_language_profile) .label("Language Profile") .icon("▼") @@ -183,10 +182,10 @@ fn draw_edit_series_confirmation_prompt(f: &mut Frame<'_>, app: &mut App<'_>, ar render_selectable_input_box!(tags_input_box, f, tags_area); } - let save_button = Button::new() + let save_button = Button::default() .title("Save") .selected(yes_no_value && highlight_yes_no); - let cancel_button = Button::new() + let cancel_button = Button::default() .title("Cancel") .selected(!yes_no_value && highlight_yes_no); diff --git a/src/ui/sonarr_ui/library/episode_details_ui.rs b/src/ui/sonarr_ui/library/episode_details_ui.rs index f5af16a..5b24319 100644 --- a/src/ui/sonarr_ui/library/episode_details_ui.rs +++ b/src/ui/sonarr_ui/library/episode_details_ui.rs @@ -13,6 +13,10 @@ use crate::ui::sonarr_ui::sonarr_ui_utils::{ create_no_data_history_event_details, }; use crate::ui::styles::ManagarrStyle; +use crate::ui::styles::{ + awaiting_import_style, downloaded_style, downloading_style, missing_style, secondary_style, + unmonitored_missing_style, unmonitored_style, unreleased_style, +}; use crate::ui::utils::{ borderless_block, decorate_peer_style, get_width_from_percentage, layout_block_bottom_border, layout_block_top_border, @@ -388,7 +392,7 @@ fn draw_history_item_details_popup(f: &mut Frame<'_>, app: &mut App<'_>, area: R let message = Message::new(text) .title("Details") - .style(Style::new().secondary()) + .style(secondary_style()) .alignment(Alignment::Left); f.render_widget(Popup::new(message).size(Size::NarrowMessage), area); @@ -602,29 +606,29 @@ fn style_from_status(download: Option<&DownloadRecord>, episode: &Episode) -> St if !episode.has_file { if let Some(download) = download { if download.status == DownloadStatus::Downloading { - return Style::new().downloading(); + return downloading_style(); } if download.status == DownloadStatus::Completed { - return Style::new().awaiting_import(); + return awaiting_import_style(); } } if !episode.monitored { - return Style::new().unmonitored_missing(); + return unmonitored_missing_style(); } if let Some(air_date) = episode.air_date_utc.as_ref() && air_date > &Utc::now() { - return Style::new().unreleased(); + return unreleased_style(); } - return Style::new().missing(); + return missing_style(); } if !episode.monitored { - Style::new().unmonitored() + unmonitored_style() } else { - Style::new().downloaded() + downloaded_style() } } diff --git a/src/ui/sonarr_ui/library/season_details_ui.rs b/src/ui/sonarr_ui/library/season_details_ui.rs index b676c6f..d7557d4 100644 --- a/src/ui/sonarr_ui/library/season_details_ui.rs +++ b/src/ui/sonarr_ui/library/season_details_ui.rs @@ -13,6 +13,7 @@ use crate::ui::sonarr_ui::sonarr_ui_utils::{ create_no_data_history_event_details, }; use crate::ui::styles::ManagarrStyle; +use crate::ui::styles::secondary_style; use crate::ui::utils::{ borderless_block, decorate_peer_style, get_width_from_percentage, layout_block_top_border, }; @@ -26,7 +27,7 @@ use crate::utils::convert_to_gb; use chrono::Utc; use ratatui::Frame; use ratatui::layout::{Alignment, Constraint, Rect}; -use ratatui::prelude::{Line, Style, Stylize, Text}; +use ratatui::prelude::{Line, Stylize, Text}; use ratatui::widgets::{Cell, Paragraph, Row, Wrap}; use serde_json::Number; @@ -567,7 +568,7 @@ fn draw_history_item_details_popup(f: &mut Frame<'_>, app: &mut App<'_>, area: R let message = Message::new(text) .title("Details") - .style(Style::new().secondary()) + .style(secondary_style()) .alignment(Alignment::Left); f.render_widget(Popup::new(message).size(Size::NarrowMessage), area); diff --git a/src/ui/sonarr_ui/library/series_details_ui.rs b/src/ui/sonarr_ui/library/series_details_ui.rs index 92364ae..42794ca 100644 --- a/src/ui/sonarr_ui/library/series_details_ui.rs +++ b/src/ui/sonarr_ui/library/series_details_ui.rs @@ -1,8 +1,9 @@ +use crate::ui::styles::secondary_style; use chrono::Utc; use deunicode::deunicode; use ratatui::Frame; use ratatui::layout::{Alignment, Constraint, Layout, Rect}; -use ratatui::style::{Style, Stylize}; +use ratatui::style::Stylize; use ratatui::text::{Line, Text}; use ratatui::widgets::{Cell, Paragraph, Row, Wrap}; use regex::Regex; @@ -157,54 +158,60 @@ fn draw_series_description(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { "Title: ".primary().bold(), current_selection.title.text.clone().primary().bold(), ]), - Line::from(vec!["Overview: ".primary().bold(), overview.default()]), + Line::from(vec![ + "Overview: ".primary().bold(), + overview.default_color(), + ]), Line::from(vec![ "Network: ".primary().bold(), current_selection .network .clone() .unwrap_or_default() - .default(), + .default_color(), ]), Line::from(vec![ "Status: ".primary().bold(), - current_selection.status.to_display_str().default(), + current_selection.status.to_display_str().default_color(), ]), Line::from(vec![ "Genres: ".primary().bold(), - current_selection.genres.join(", ").default(), + current_selection.genres.join(", ").default_color(), ]), Line::from(vec![ "Rating: ".primary().bold(), - format!("{}%", (current_selection.ratings.value * 10.0) as i32).default(), + format!("{}%", (current_selection.ratings.value * 10.0) as i32).default_color(), ]), Line::from(vec![ "Year: ".primary().bold(), - current_selection.year.to_string().default(), + current_selection.year.to_string().default_color(), ]), Line::from(vec![ "Runtime: ".primary().bold(), - format!("{} minutes", current_selection.runtime).default(), + format!("{} minutes", current_selection.runtime).default_color(), ]), Line::from(vec![ "Path: ".primary().bold(), - current_selection.path.clone().default(), + current_selection.path.clone().default_color(), ]), Line::from(vec![ "Quality Profile: ".primary().bold(), - quality_profile.default(), + quality_profile.default_color(), ]), Line::from(vec![ "Language Profile: ".primary().bold(), - language_profile.default(), + language_profile.default_color(), + ]), + Line::from(vec![ + "Monitored: ".primary().bold(), + monitored.default_color(), ]), - Line::from(vec!["Monitored: ".primary().bold(), monitored.default()]), ]; if let Some(stats) = current_selection.statistics.as_ref() { let size = convert_to_gb(stats.size_on_disk); series_description.extend(vec![Line::from(vec![ "Size on Disk: ".primary().bold(), - format!("{size:.2} GB").default(), + format!("{size:.2} GB").default_color(), ])]); } @@ -421,7 +428,7 @@ fn draw_history_item_details_popup(f: &mut Frame<'_>, app: &mut App<'_>, area: R let message = Message::new(text) .title("Details") - .style(Style::new().secondary()) + .style(secondary_style()) .alignment(Alignment::Left); f.render_widget(Popup::new(message).size(Size::NarrowMessage), area); diff --git a/src/ui/sonarr_ui/mod.rs b/src/ui/sonarr_ui/mod.rs index bbe1979..5469e8a 100644 --- a/src/ui/sonarr_ui/mod.rs +++ b/src/ui/sonarr_ui/mod.rs @@ -173,7 +173,7 @@ fn draw_stats_context(f: &mut Frame<'_>, app: &App<'_>, area: Rect) { let space: f64 = convert_to_gb(*free_space); let root_folder_space = Paragraph::new(format!("{path}: {space:.2} GB free")) .block(borderless_block()) - .default(); + .default_color(); f.render_widget( root_folder_space, @@ -224,7 +224,7 @@ fn draw_sonarr_logo(f: &mut Frame<'_>, area: Rect) { let logo_text = Text::from(SONARR_LOGO); let logo = Paragraph::new(logo_text) .light_cyan() - .block(layout_block().default()) + .block(layout_block().default_color()) .centered(); f.render_widget(logo, area); } diff --git a/src/ui/sonarr_ui/system/mod.rs b/src/ui/sonarr_ui/system/mod.rs index 96d06ee..e983762 100644 --- a/src/ui/sonarr_ui/system/mod.rs +++ b/src/ui/sonarr_ui/system/mod.rs @@ -1,3 +1,4 @@ +use crate::ui::styles::default_style; use std::ops::Sub; #[cfg(test)] @@ -5,7 +6,6 @@ use crate::ui::ui_test_utils::test_utils::Utc; #[cfg(not(test))] use chrono::Utc; use ratatui::layout::Layout; -use ratatui::style::Style; use ratatui::text::{Span, Text}; use ratatui::widgets::{Cell, Row}; use ratatui::{ @@ -171,7 +171,7 @@ fn draw_logs(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { style_log_list_item(ListItem::new(Text::from(Span::raw(log_line))), level) }) .block(block) - .highlight_style(Style::new().default()); + .highlight_style(default_style()); f.render_widget(logs_box, area); } diff --git a/src/ui/styles.rs b/src/ui/styles.rs index 306f794..6d0f202 100644 --- a/src/ui/styles.rs +++ b/src/ui/styles.rs @@ -1,253 +1,303 @@ use crate::ui::THEME; -use ratatui::style::{Styled, Stylize}; +use ratatui::style::{Style, Styled}; #[cfg(test)] #[path = "styles_tests.rs"] mod styles_tests; -pub trait ManagarrStyle<'a, T>: Stylize<'a, T> -where - T: Default, -{ - #[allow(clippy::new_ret_no_self)] - fn new() -> T; - fn awaiting_import(self) -> T; - fn indeterminate(self) -> T; - fn default(self) -> T; - fn downloaded(self) -> T; - fn downloading(self) -> T; - fn failure(self) -> T; - fn help(self) -> T; - fn highlight(self) -> T; - fn missing(self) -> T; - fn primary(self) -> T; - fn secondary(self) -> T; - fn success(self) -> T; - fn system_function(self) -> T; - fn unmonitored(self) -> T; - fn unmonitored_missing(self) -> T; - fn unreleased(self) -> T; - fn warning(self) -> T; +pub fn awaiting_import_style() -> Style { + THEME.with(|theme| { + Style::new().fg( + theme + .get() + .awaiting_import + .expect("awaiting_import style must be defined in theme") + .color + .expect("awaiting_import color must be defined"), + ) + }) } -impl ManagarrStyle<'_, T> for U -where - U: Styled, - T: Default, -{ - fn new() -> T { - T::default() +pub fn indeterminate_style() -> Style { + THEME.with(|theme| { + Style::new().fg( + theme + .get() + .indeterminate + .expect("indeterminate style must be defined in theme") + .color + .expect("indeterminate color must be defined"), + ) + }) +} + +pub fn default_style() -> Style { + THEME.with(|theme| { + Style::new().fg( + theme + .get() + .default + .expect("default style must be defined in theme") + .color + .expect("default color must be defined"), + ) + }) +} + +pub fn downloaded_style() -> Style { + THEME.with(|theme| { + Style::new().fg( + theme + .get() + .downloaded + .expect("downloaded style must be defined in theme") + .color + .expect("downloaded color must be defined"), + ) + }) +} + +pub fn downloading_style() -> Style { + THEME.with(|theme| { + Style::new().fg( + theme + .get() + .downloading + .expect("downloading style must be defined in theme") + .color + .expect("downloading color must be defined"), + ) + }) +} + +pub fn failure_style() -> Style { + THEME.with(|theme| { + Style::new().fg( + theme + .get() + .failure + .expect("failure style must be defined in theme") + .color + .expect("failure color must be defined"), + ) + }) +} + +pub fn help_style() -> Style { + THEME.with(|theme| { + Style::new().fg( + theme + .get() + .help + .expect("help style must be defined in theme") + .color + .expect("help color must be defined"), + ) + }) +} + +pub fn highlight_style() -> Style { + Style::new().reversed() +} + +pub fn missing_style() -> Style { + THEME.with(|theme| { + Style::new().fg( + theme + .get() + .missing + .expect("missing style must be defined in theme") + .color + .expect("missing color must be defined"), + ) + }) +} + +pub fn primary_style() -> Style { + THEME.with(|theme| { + Style::new().fg( + theme + .get() + .primary + .expect("primary style must be defined in theme") + .color + .expect("primary color must be defined"), + ) + }) +} + +pub fn secondary_style() -> Style { + THEME.with(|theme| { + Style::new().fg( + theme + .get() + .secondary + .expect("secondary style must be defined in theme") + .color + .expect("secondary color must be defined"), + ) + }) +} + +pub fn success_style() -> Style { + THEME.with(|theme| { + Style::new().fg( + theme + .get() + .success + .expect("success style must be defined in theme") + .color + .expect("success color must be defined"), + ) + }) +} + +pub fn system_function_style() -> Style { + THEME.with(|theme| { + Style::new().fg( + theme + .get() + .system_function + .expect("system_function style must be defined in theme") + .color + .expect("system_function color must be defined"), + ) + }) +} + +pub fn unmonitored_style() -> Style { + THEME.with(|theme| { + Style::new().fg( + theme + .get() + .unmonitored + .expect("unmonitored style must be defined in theme") + .color + .expect("unmonitored color must be defined"), + ) + }) +} + +pub fn unmonitored_missing_style() -> Style { + THEME.with(|theme| { + Style::new().fg( + theme + .get() + .unmonitored_missing + .expect("unmonitored_missing style must be defined in theme") + .color + .expect("unmonitored_missing color must be defined"), + ) + }) +} + +pub fn unreleased_style() -> Style { + THEME.with(|theme| { + Style::new().fg( + theme + .get() + .unreleased + .expect("unreleased style must be defined in theme") + .color + .expect("unreleased color must be defined"), + ) + }) +} + +pub fn warning_style() -> Style { + THEME.with(|theme| { + Style::new().fg( + theme + .get() + .warning + .expect("warning style must be defined in theme") + .color + .expect("warning color must be defined"), + ) + }) +} + +pub trait ManagarrStyle: Styled { + fn awaiting_import(self) -> Self::Item; + fn indeterminate(self) -> Self::Item; + fn default_color(self) -> Self::Item; + fn downloaded(self) -> Self::Item; + fn downloading(self) -> Self::Item; + fn failure(self) -> Self::Item; + fn help(self) -> Self::Item; + fn missing(self) -> Self::Item; + fn primary(self) -> Self::Item; + fn secondary(self) -> Self::Item; + fn success(self) -> Self::Item; + fn system_function(self) -> Self::Item; + fn unmonitored(self) -> Self::Item; + fn unmonitored_missing(self) -> Self::Item; + fn unreleased(self) -> Self::Item; + fn warning(self) -> Self::Item; +} + +impl ManagarrStyle for T { + fn awaiting_import(self) -> Self::Item { + self.set_style(awaiting_import_style()) } - fn awaiting_import(self) -> T { - THEME.with(|theme| { - self.fg( - theme - .get() - .awaiting_import - .expect("awaiting_import style must be defined in theme") - .color - .expect("awaiting_import color must be defined"), - ) - }) + fn indeterminate(self) -> Self::Item { + self.set_style(indeterminate_style()) } - fn indeterminate(self) -> T { - THEME.with(|theme| { - self.fg( - theme - .get() - .indeterminate - .expect("indeterminate style must be defined in theme") - .color - .expect("indeterminate color must be defined"), - ) - }) + fn default_color(self) -> Self::Item { + self.set_style(default_style()) } - fn default(self) -> T { - THEME.with(|theme| { - self.fg( - theme - .get() - .default - .expect("default style must be defined in theme") - .color - .expect("default color must be defined"), - ) - }) + fn downloaded(self) -> Self::Item { + self.set_style(downloaded_style()) } - fn downloaded(self) -> T { - THEME.with(|theme| { - self.fg( - theme - .get() - .downloaded - .expect("downloaded style must be defined in theme") - .color - .expect("downloaded color must be defined"), - ) - }) + fn downloading(self) -> Self::Item { + self.set_style(downloading_style()) } - fn downloading(self) -> T { - THEME.with(|theme| { - self.fg( - theme - .get() - .downloading - .expect("downloading style must be defined in theme") - .color - .expect("downloading color must be defined"), - ) - }) + fn failure(self) -> Self::Item { + self.set_style(failure_style()) } - fn failure(self) -> T { - THEME.with(|theme| { - self.fg( - theme - .get() - .failure - .expect("failure style must be defined in theme") - .color - .expect("failure color must be defined"), - ) - }) + fn help(self) -> Self::Item { + self.set_style(help_style()) } - fn help(self) -> T { - THEME.with(|theme| { - self.fg( - theme - .get() - .help - .expect("help style must be defined in theme") - .color - .expect("help color must be defined"), - ) - }) + fn missing(self) -> Self::Item { + self.set_style(missing_style()) } - fn highlight(self) -> T { - self.reversed() + fn primary(self) -> Self::Item { + self.set_style(primary_style()) } - fn missing(self) -> T { - THEME.with(|theme| { - self.fg( - theme - .get() - .missing - .expect("missing style must be defined in theme") - .color - .expect("missing color must be defined"), - ) - }) + fn secondary(self) -> Self::Item { + self.set_style(secondary_style()) } - fn primary(self) -> T { - THEME.with(|theme| { - self.fg( - theme - .get() - .primary - .expect("primary style must be defined in theme") - .color - .expect("primary color must be defined"), - ) - }) + fn success(self) -> Self::Item { + self.set_style(success_style()) } - fn secondary(self) -> T { - THEME.with(|theme| { - self.fg( - theme - .get() - .secondary - .expect("secondary style must be defined in theme") - .color - .expect("secondary color must be defined"), - ) - }) + fn system_function(self) -> Self::Item { + self.set_style(system_function_style()) } - fn success(self) -> T { - THEME.with(|theme| { - self.fg( - theme - .get() - .success - .expect("success style must be defined in theme") - .color - .expect("success color must be defined"), - ) - }) + fn unmonitored(self) -> Self::Item { + self.set_style(unmonitored_style()) } - fn system_function(self) -> T { - THEME.with(|theme| { - self.fg( - theme - .get() - .system_function - .expect("system_function style must be defined in theme") - .color - .expect("system_function color must be defined"), - ) - }) + fn unmonitored_missing(self) -> Self::Item { + self.set_style(unmonitored_missing_style()) } - fn unmonitored(self) -> T { - THEME.with(|theme| { - self.fg( - theme - .get() - .unmonitored - .expect("unmonitored style must be defined in theme") - .color - .expect("unmonitored color must be defined"), - ) - }) + fn unreleased(self) -> Self::Item { + self.set_style(unreleased_style()) } - fn unmonitored_missing(self) -> T { - THEME.with(|theme| { - self.fg( - theme - .get() - .unmonitored_missing - .expect("unmonitored_missing style must be defined in theme") - .color - .expect("unmonitored_missing color must be defined"), - ) - }) - } - - fn unreleased(self) -> T { - THEME.with(|theme| { - self.fg( - theme - .get() - .unreleased - .expect("unreleased style must be defined in theme") - .color - .expect("unreleased color must be defined"), - ) - }) - } - - fn warning(self) -> T { - THEME.with(|theme| { - self.fg( - theme - .get() - .warning - .expect("warning style must be defined in theme") - .color - .expect("warning color must be defined"), - ) - }) + fn warning(self) -> Self::Item { + self.set_style(warning_style()) } } diff --git a/src/ui/styles_tests.rs b/src/ui/styles_tests.rs index 2ec748f..ee61ee4 100644 --- a/src/ui/styles_tests.rs +++ b/src/ui/styles_tests.rs @@ -1,19 +1,19 @@ #[cfg(test)] mod test { - use crate::ui::styles::ManagarrStyle; + use crate::ui::styles::{ + awaiting_import_style, default_style, downloaded_style, downloading_style, failure_style, + help_style, highlight_style, indeterminate_style, missing_style, primary_style, + secondary_style, success_style, system_function_style, unmonitored_missing_style, + unmonitored_style, unreleased_style, warning_style, + }; use pretty_assertions::assert_eq; use ratatui::prelude::Modifier; - use ratatui::style::{Color, Style, Stylize}; - - #[test] - fn test_new() { - assert_eq!(Style::new(),