feat: CLI support for listing artists

This commit is contained in:
2026-01-05 10:58:48 -07:00
parent 368f7505ff
commit 5d09b2402c
14 changed files with 405 additions and 15 deletions
+37 -1
View File
@@ -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<String>,
pub lidarr: Option<Vec<ServarrConfig>>,
pub radarr: Option<Vec<ServarrConfig>>,
pub sonarr: Option<Vec<ServarrConfig>>,
}
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();
}
}
}
}
+59
View File
@@ -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<LidarrListCommand> for Command {
fn from(value: LidarrListCommand) -> Self {
Command::Lidarr(LidarrCommand::List(value))
}
}
pub(super) struct LidarrListCommandHandler<'a, 'b> {
_app: &'a Arc<Mutex<App<'b>>>,
command: LidarrListCommand,
network: &'a mut dyn NetworkTrait,
}
impl<'a, 'b> CliCommandHandler<'a, 'b, LidarrListCommand> for LidarrListCommandHandler<'a, 'b> {
fn with(
app: &'a Arc<Mutex<App<'b>>>,
command: LidarrListCommand,
network: &'a mut dyn NetworkTrait,
) -> Self {
LidarrListCommandHandler {
_app: app,
command,
network,
}
}
async fn handle(self) -> Result<String> {
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)
}
}
+62
View File
@@ -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<LidarrCommand> for Command {
fn from(lidarr_command: LidarrCommand) -> Command {
Command::Lidarr(lidarr_command)
}
}
pub(super) struct LidarrCliHandler<'a, 'b> {
app: &'a Arc<Mutex<App<'b>>>,
command: LidarrCommand,
network: &'a mut dyn NetworkTrait,
}
impl<'a, 'b> CliCommandHandler<'a, 'b, LidarrCommand> for LidarrCliHandler<'a, 'b> {
fn with(
app: &'a Arc<Mutex<App<'b>>>,
command: LidarrCommand,
network: &'a mut dyn NetworkTrait,
) -> Self {
LidarrCliHandler {
app,
command,
network,
}
}
async fn handle(self) -> Result<String> {
let result = match self.command {
LidarrCommand::List(list_command) => {
LidarrListCommandHandler::with(self.app, list_command, self.network)
.handle()
.await?
}
};
Ok(result)
}
}
+10
View File
@@ -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(),
};
+1 -1
View File
@@ -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 {
+84
View File
@@ -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<String>,
pub artist_type: Option<String>,
pub disambiguation: Option<String>,
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<String>,
pub tags: Vec<Number>,
pub added: DateTime<Utc>,
pub ratings: Option<Ratings>,
pub statistics: Option<ArtistStatistics>,
}
#[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<LidarrSerdeable> for Serdeable {
fn from(value: LidarrSerdeable) -> Serdeable {
Serdeable::Lidarr(value)
}
}
serde_enum_from!(
LidarrSerdeable {
Artists(Vec<Artist>),
Value(Value),
}
);
+8 -5
View File
@@ -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<ActiveRadarrBlock>),
Sonarr(ActiveSonarrBlock, Option<ActiveSonarrBlock>),
Readarr,
Lidarr,
Lidarr(ActiveLidarrBlock, Option<ActiveLidarrBlock>),
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]
@@ -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<ActiveLidarrBlock> for Route {
fn from(active_lidarr_block: ActiveLidarrBlock) -> Route {
Route::Lidarr(active_lidarr_block, None)
}
}
impl From<(ActiveLidarrBlock, Option<ActiveLidarrBlock>)> for Route {
fn from(value: (ActiveLidarrBlock, Option<ActiveLidarrBlock>)) -> Route {
Route::Lidarr(value.0, value.1)
}
}
@@ -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),)
);
}
}
+1
View File
@@ -0,0 +1 @@
pub mod lidarr_data;
+1
View File
@@ -1,5 +1,6 @@
use crate::models::Route;
pub mod lidarr;
pub mod modals;
pub mod radarr;
pub mod sonarr;
+70
View File
@@ -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<LidarrEvent> 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<LidarrSerdeable> {
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<Vec<Artist>> {
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<Artist>>(request_props, |_, _| ())
.await
}
}
+14 -5
View File
@@ -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)
)
};
+8 -3
View File
@@ -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);
}
_ => (),
}
}