From 5d09b2402c88ef2933854ec029423552bd4b51bf Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Mon, 5 Jan 2026 10:58:48 -0700 Subject: [PATCH] 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); + } _ => (), } }