feat: Completed support for viewing Lidarr artist details

This commit is contained in:
2026-01-09 16:22:03 -07:00
parent 269057867f
commit b2814371f0
74 changed files with 3488 additions and 567 deletions
+25 -1
View File
@@ -5,7 +5,7 @@ use crate::app::context_clues::{
use crate::app::key_binding::DEFAULT_KEYBINDINGS; use crate::app::key_binding::DEFAULT_KEYBINDINGS;
use crate::models::Route; use crate::models::Route;
use crate::models::servarr_data::lidarr::lidarr_data::{ use crate::models::servarr_data::lidarr::lidarr_data::{
ADD_ARTIST_BLOCKS, ActiveLidarrBlock, EDIT_ARTIST_BLOCKS, ADD_ARTIST_BLOCKS, ARTIST_DETAILS_BLOCKS, ActiveLidarrBlock, EDIT_ARTIST_BLOCKS,
}; };
#[cfg(test)] #[cfg(test)]
@@ -36,6 +36,25 @@ pub static ADD_ARTIST_SEARCH_RESULTS_CONTEXT_CLUES: [ContextClue; 2] = [
(DEFAULT_KEYBINDINGS.esc, "edit search"), (DEFAULT_KEYBINDINGS.esc, "edit search"),
]; ];
pub static ARTIST_DETAILS_CONTEXT_CLUES: [ContextClue; 7] = [
(
DEFAULT_KEYBINDINGS.refresh,
DEFAULT_KEYBINDINGS.refresh.desc,
),
(DEFAULT_KEYBINDINGS.edit, DEFAULT_KEYBINDINGS.edit.desc),
(
DEFAULT_KEYBINDINGS.toggle_monitoring,
DEFAULT_KEYBINDINGS.toggle_monitoring.desc,
),
(DEFAULT_KEYBINDINGS.search, DEFAULT_KEYBINDINGS.search.desc),
(DEFAULT_KEYBINDINGS.update, DEFAULT_KEYBINDINGS.update.desc),
(
DEFAULT_KEYBINDINGS.auto_search,
DEFAULT_KEYBINDINGS.auto_search.desc,
),
(DEFAULT_KEYBINDINGS.esc, DEFAULT_KEYBINDINGS.esc.desc),
];
pub(in crate::app) struct LidarrContextClueProvider; pub(in crate::app) struct LidarrContextClueProvider;
impl ContextClueProvider for LidarrContextClueProvider { impl ContextClueProvider for LidarrContextClueProvider {
@@ -45,6 +64,11 @@ impl ContextClueProvider for LidarrContextClueProvider {
}; };
match active_lidarr_block { match active_lidarr_block {
_ if ARTIST_DETAILS_BLOCKS.contains(&active_lidarr_block) => app
.data
.lidarr_data
.artist_info_tabs
.get_active_route_contextual_help(),
ActiveLidarrBlock::AddArtistSearchInput | ActiveLidarrBlock::AddArtistEmptySearchResults => { ActiveLidarrBlock::AddArtistSearchInput | ActiveLidarrBlock::AddArtistEmptySearchResults => {
Some(&BARE_POPUP_CONTEXT_CLUES) Some(&BARE_POPUP_CONTEXT_CLUES)
} }
+67 -3
View File
@@ -2,13 +2,16 @@
mod tests { mod tests {
use crate::app::App; use crate::app::App;
use crate::app::context_clues::{ use crate::app::context_clues::{
BARE_POPUP_CONTEXT_CLUES, CONFIRMATION_PROMPT_CONTEXT_CLUES, ContextClueProvider, BARE_POPUP_CONTEXT_CLUES, CONFIRMATION_PROMPT_CONTEXT_CLUES, ContextClue, ContextClueProvider,
}; };
use crate::app::key_binding::DEFAULT_KEYBINDINGS; use crate::app::key_binding::DEFAULT_KEYBINDINGS;
use crate::app::lidarr::lidarr_context_clues::{ use crate::app::lidarr::lidarr_context_clues::{
ADD_ARTIST_SEARCH_RESULTS_CONTEXT_CLUES, ARTISTS_CONTEXT_CLUES, LidarrContextClueProvider, ADD_ARTIST_SEARCH_RESULTS_CONTEXT_CLUES, ARTIST_DETAILS_CONTEXT_CLUES, ARTISTS_CONTEXT_CLUES,
LidarrContextClueProvider,
};
use crate::models::servarr_data::lidarr::lidarr_data::{
ActiveLidarrBlock, EDIT_ARTIST_BLOCKS, LidarrData,
}; };
use crate::models::servarr_data::lidarr::lidarr_data::{ActiveLidarrBlock, EDIT_ARTIST_BLOCKS};
use crate::models::servarr_data::radarr::radarr_data::ActiveRadarrBlock; use crate::models::servarr_data::radarr::radarr_data::ActiveRadarrBlock;
use rstest::rstest; use rstest::rstest;
@@ -65,6 +68,50 @@ mod tests {
assert_none!(artists_context_clues_iter.next()); assert_none!(artists_context_clues_iter.next());
} }
#[test]
fn test_artist_details_context_clues() {
let mut artist_details_context_clues_iter = ARTIST_DETAILS_CONTEXT_CLUES.iter();
assert_some_eq_x!(
artist_details_context_clues_iter.next(),
&(
DEFAULT_KEYBINDINGS.refresh,
DEFAULT_KEYBINDINGS.refresh.desc,
)
);
assert_some_eq_x!(
artist_details_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.edit, DEFAULT_KEYBINDINGS.edit.desc)
);
assert_some_eq_x!(
artist_details_context_clues_iter.next(),
&(
DEFAULT_KEYBINDINGS.toggle_monitoring,
DEFAULT_KEYBINDINGS.toggle_monitoring.desc,
)
);
assert_some_eq_x!(
artist_details_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.search, DEFAULT_KEYBINDINGS.search.desc)
);
assert_some_eq_x!(
artist_details_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.update, DEFAULT_KEYBINDINGS.update.desc)
);
assert_some_eq_x!(
artist_details_context_clues_iter.next(),
&(
DEFAULT_KEYBINDINGS.auto_search,
DEFAULT_KEYBINDINGS.auto_search.desc,
)
);
assert_some_eq_x!(
artist_details_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.esc, DEFAULT_KEYBINDINGS.esc.desc)
);
assert_none!(artist_details_context_clues_iter.next());
}
#[test] #[test]
fn test_add_artist_search_results_context_clues() { fn test_add_artist_search_results_context_clues() {
let mut add_artist_search_results_context_clues_iter = let mut add_artist_search_results_context_clues_iter =
@@ -92,6 +139,23 @@ mod tests {
LidarrContextClueProvider::get_context_clues(&mut app); LidarrContextClueProvider::get_context_clues(&mut app);
} }
#[rstest]
#[case(0, ActiveLidarrBlock::ArtistDetails, &ARTIST_DETAILS_CONTEXT_CLUES)]
fn test_lidarr_context_clue_provider_artist_info_tabs(
#[case] index: usize,
#[case] active_lidarr_block: ActiveLidarrBlock,
#[case] expected_context_clues: &[ContextClue],
) {
let mut app = App::test_default();
app.data.lidarr_data = LidarrData::default();
app.data.lidarr_data.artist_info_tabs.set_index(index);
app.push_navigation_stack(active_lidarr_block.into());
let context_clues = LidarrContextClueProvider::get_context_clues(&mut app);
assert_some_eq_x!(context_clues, expected_context_clues);
}
#[test] #[test]
fn test_lidarr_context_clue_provider_artists_block() { fn test_lidarr_context_clue_provider_artists_block() {
let mut app = App::test_default(); let mut app = App::test_default();
+26
View File
@@ -4,6 +4,7 @@ mod tests {
use crate::models::servarr_data::lidarr::lidarr_data::ActiveLidarrBlock; use crate::models::servarr_data::lidarr::lidarr_data::ActiveLidarrBlock;
use crate::network::NetworkEvent; use crate::network::NetworkEvent;
use crate::network::lidarr_network::LidarrEvent; use crate::network::lidarr_network::LidarrEvent;
use crate::network::lidarr_network::lidarr_network_test_utils::test_utils::artist;
use pretty_assertions::{assert_eq, assert_str_eq}; use pretty_assertions::{assert_eq, assert_str_eq};
use tokio::sync::mpsc; use tokio::sync::mpsc;
@@ -32,6 +33,23 @@ mod tests {
assert_eq!(app.tick_count, 0); assert_eq!(app.tick_count, 0);
} }
#[tokio::test]
async fn test_dispatch_by_lidarr_block_artist_details() {
let (tx, mut rx) = mpsc::channel::<NetworkEvent>(500);
let mut app = App::test_default();
app.data.lidarr_data.artists.set_items(vec![artist()]);
app.network_tx = Some(tx);
app
.dispatch_by_lidarr_block(&ActiveLidarrBlock::ArtistDetails)
.await;
assert!(app.is_loading);
assert_eq!(rx.recv().await.unwrap(), LidarrEvent::GetAlbums(1).into());
assert!(!app.data.sonarr_data.prompt_confirm);
assert_eq!(app.tick_count, 0);
}
#[tokio::test] #[tokio::test]
async fn test_dispatch_by_lidarr_block_add_artist_search_results() { async fn test_dispatch_by_lidarr_block_add_artist_search_results() {
let (tx, mut rx) = mpsc::channel::<NetworkEvent>(500); let (tx, mut rx) = mpsc::channel::<NetworkEvent>(500);
@@ -60,4 +78,12 @@ mod tests {
assert_str_eq!(query, "Test Artist"); assert_str_eq!(query, "Test Artist");
} }
#[tokio::test]
async fn test_extract_artist_id() {
let mut app = App::test_default();
app.data.lidarr_data.artists.set_items(vec![artist()]);
assert_eq!(app.extract_artist_id().await, 1);
}
} }
+9
View File
@@ -28,6 +28,11 @@ impl App<'_> {
.dispatch_network_event(LidarrEvent::ListArtists.into()) .dispatch_network_event(LidarrEvent::ListArtists.into())
.await; .await;
} }
ActiveLidarrBlock::ArtistDetails => {
self
.dispatch_network_event(LidarrEvent::GetAlbums(self.extract_artist_id().await).into())
.await;
}
ActiveLidarrBlock::AddArtistSearchResults => { ActiveLidarrBlock::AddArtistSearchResults => {
self self
.dispatch_network_event( .dispatch_network_event(
@@ -53,6 +58,10 @@ impl App<'_> {
.clone() .clone()
} }
async fn extract_artist_id(&self) -> i64 {
self.data.lidarr_data.artists.current_selection().id
}
async fn check_for_lidarr_prompt_action(&mut self) { async fn check_for_lidarr_prompt_action(&mut self) {
if self.data.lidarr_data.prompt_confirm { if self.data.lidarr_data.prompt_confirm {
self.data.lidarr_data.prompt_confirm = false; self.data.lidarr_data.prompt_confirm = false;
+16
View File
@@ -18,6 +18,15 @@ mod get_command_handler_tests;
#[derive(Debug, Clone, PartialEq, Eq, Subcommand)] #[derive(Debug, Clone, PartialEq, Eq, Subcommand)]
pub enum LidarrGetCommand { pub enum LidarrGetCommand {
#[command(about = "Get detailed information for the album with the given ID")]
AlbumDetails {
#[arg(
long,
help = "The Lidarr ID of the album whose details you wish to fetch",
required = true
)]
album_id: i64,
},
#[command(about = "Get detailed information for the artist with the given ID")] #[command(about = "Get detailed information for the artist with the given ID")]
ArtistDetails { ArtistDetails {
#[arg( #[arg(
@@ -62,6 +71,13 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, LidarrGetCommand> for LidarrGetCommandHan
async fn handle(self) -> Result<String> { async fn handle(self) -> Result<String> {
let result = match self.command { let result = match self.command {
LidarrGetCommand::AlbumDetails { album_id } => {
let resp = self
.network
.handle_network_event(LidarrEvent::GetAlbumDetails(album_id).into())
.await?;
serde_json::to_string_pretty(&resp)?
}
LidarrGetCommand::ArtistDetails { artist_id } => { LidarrGetCommand::ArtistDetails { artist_id } => {
let resp = self let resp = self
.network .network
@@ -23,6 +23,32 @@ mod tests {
use super::*; use super::*;
#[test]
fn test_album_details_requires_album_id() {
let result =
Cli::command().try_get_matches_from(["managarr", "lidarr", "get", "album-details"]);
assert_err!(&result);
assert_eq!(
result.unwrap_err().kind(),
ErrorKind::MissingRequiredArgument
);
}
#[test]
fn test_album_details_requirements_satisfied() {
let result = Cli::command().try_get_matches_from([
"managarr",
"lidarr",
"get",
"album-details",
"--album-id",
"1",
]);
assert_ok!(&result);
}
#[test] #[test]
fn test_artist_details_requires_artist_id() { fn test_artist_details_requires_artist_id() {
let result = let result =
@@ -91,6 +117,32 @@ mod tests {
network::{MockNetworkTrait, NetworkEvent, lidarr_network::LidarrEvent}, network::{MockNetworkTrait, NetworkEvent, lidarr_network::LidarrEvent},
}; };
#[tokio::test]
async fn test_handle_get_album_details_command() {
let expected_album_id = 1;
let mut mock_network = MockNetworkTrait::new();
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(
LidarrEvent::GetAlbumDetails(expected_album_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_album_details_command = LidarrGetCommand::AlbumDetails { album_id: 1 };
let result =
LidarrGetCommandHandler::with(&app_arc, get_album_details_command, &mut mock_network)
.handle()
.await;
assert_ok!(&result);
}
#[tokio::test] #[tokio::test]
async fn test_handle_get_artist_details_command() { async fn test_handle_get_artist_details_command() {
let expected_artist_id = 1; let expected_artist_id = 1;
+58
View File
@@ -75,6 +75,31 @@ mod tests {
assert_ok!(&result); assert_ok!(&result);
} }
#[test]
fn test_toggle_album_monitoring_requires_album_id() {
let result =
Cli::command().try_get_matches_from(["managarr", "lidarr", "toggle-album-monitoring"]);
assert_err!(&result);
assert_eq!(
result.unwrap_err().kind(),
ErrorKind::MissingRequiredArgument
);
}
#[test]
fn test_toggle_album_monitoring_requirements_satisfied() {
let result = Cli::command().try_get_matches_from([
"managarr",
"lidarr",
"toggle-album-monitoring",
"--album-id",
"1",
]);
assert_ok!(&result);
}
#[test] #[test]
fn test_search_new_artist_requires_query() { fn test_search_new_artist_requires_query() {
let result = Cli::command().try_get_matches_from(["managarr", "lidarr", "search-new-artist"]); let result = Cli::command().try_get_matches_from(["managarr", "lidarr", "search-new-artist"]);
@@ -110,6 +135,7 @@ mod tests {
use crate::cli::lidarr::add_command_handler::LidarrAddCommand; use crate::cli::lidarr::add_command_handler::LidarrAddCommand;
use crate::cli::lidarr::get_command_handler::LidarrGetCommand; use crate::cli::lidarr::get_command_handler::LidarrGetCommand;
use crate::cli::lidarr::refresh_command_handler::LidarrRefreshCommand; use crate::cli::lidarr::refresh_command_handler::LidarrRefreshCommand;
use crate::cli::lidarr::trigger_automatic_search_command_handler::LidarrTriggerAutomaticSearchCommand;
use crate::{ use crate::{
app::App, app::App,
cli::{ cli::{
@@ -306,5 +332,37 @@ mod tests {
assert_ok!(&result); assert_ok!(&result);
} }
#[tokio::test]
async fn test_lidarr_cli_handler_delegates_trigger_automatic_search_commands_to_the_trigger_automatic_search_command_handler()
{
let mut mock_network = MockNetworkTrait::new();
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(
LidarrEvent::TriggerAutomaticArtistSearch(1).into(),
))
.times(1)
.returning(|_| {
Ok(Serdeable::Lidarr(LidarrSerdeable::Value(
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::test_default()));
let trigger_automatic_search_command =
LidarrCommand::TriggerAutomaticSearch(LidarrTriggerAutomaticSearchCommand::Artist {
artist_id: 1,
});
let result = LidarrCliHandler::with(
&app_arc,
trigger_automatic_search_command,
&mut mock_network,
)
.handle()
.await;
assert_ok!(&result);
}
} }
} }
+17 -1
View File
@@ -1,7 +1,7 @@
use std::sync::Arc; use std::sync::Arc;
use anyhow::Result; use anyhow::Result;
use clap::Subcommand; use clap::{Subcommand, arg};
use tokio::sync::Mutex; use tokio::sync::Mutex;
use crate::{ use crate::{
@@ -18,6 +18,15 @@ mod list_command_handler_tests;
#[derive(Debug, Clone, PartialEq, Eq, Subcommand)] #[derive(Debug, Clone, PartialEq, Eq, Subcommand)]
pub enum LidarrListCommand { pub enum LidarrListCommand {
#[command(about = "List all albums for the artist with the given ID")]
Albums {
#[arg(
long,
help = "The Lidarr ID of the artist whose albums you want to list",
required = true
)]
artist_id: i64,
},
#[command(about = "List all artists in your Lidarr library")] #[command(about = "List all artists in your Lidarr library")]
Artists, Artists,
#[command(about = "List all Lidarr metadata profiles")] #[command(about = "List all Lidarr metadata profiles")]
@@ -55,6 +64,13 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, LidarrListCommand> for LidarrListCommandH
async fn handle(self) -> Result<String> { async fn handle(self) -> Result<String> {
let result = match self.command { let result = match self.command {
LidarrListCommand::Albums { artist_id } => {
let resp = self
.network
.handle_network_event(LidarrEvent::GetAlbums(artist_id).into())
.await?;
serde_json::to_string_pretty(&resp)?
}
LidarrListCommand::Artists => { LidarrListCommand::Artists => {
let resp = self let resp = self
.network .network
@@ -19,6 +19,8 @@ mod tests {
mod cli { mod cli {
use super::*; use super::*;
use clap::{Parser, error::ErrorKind};
use pretty_assertions::assert_eq;
use rstest::rstest; use rstest::rstest;
#[rstest] #[rstest]
@@ -29,6 +31,32 @@ mod tests {
assert_ok!(&result); assert_ok!(&result);
} }
#[test]
fn test_list_albums_requires_artist_id() {
let result = Cli::command().try_get_matches_from(["managarr", "lidarr", "list", "albums"]);
assert_err!(&result);
assert_eq!(
result.unwrap_err().kind(),
ErrorKind::MissingRequiredArgument
);
}
#[test]
fn test_list_albums_with_artist_id() {
let expected_args = LidarrListCommand::Albums { artist_id: 1 };
let result =
Cli::try_parse_from(["managarr", "lidarr", "list", "albums", "--artist-id", "1"]);
assert_ok!(&result);
let Some(Command::Lidarr(LidarrCommand::List(album_command))) = result.unwrap().command
else {
panic!("Unexpected command type");
};
assert_eq!(album_command, expected_args);
}
} }
mod handler { mod handler {
@@ -77,5 +105,27 @@ mod tests {
assert_ok!(&result); assert_ok!(&result);
} }
#[tokio::test]
async fn test_handle_list_albums_command() {
let mut mock_network = MockNetworkTrait::new();
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(LidarrEvent::GetAlbums(1).into()))
.times(1)
.returning(|_| {
Ok(Serdeable::Lidarr(LidarrSerdeable::Value(
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::test_default()));
let list_command = LidarrListCommand::Albums { artist_id: 1 };
let result = LidarrListCommandHandler::with(&app_arc, list_command, &mut mock_network)
.handle()
.await;
assert_ok!(&result);
}
} }
} }
+36
View File
@@ -9,6 +9,9 @@ use get_command_handler::{LidarrGetCommand, LidarrGetCommandHandler};
use list_command_handler::{LidarrListCommand, LidarrListCommandHandler}; use list_command_handler::{LidarrListCommand, LidarrListCommandHandler};
use refresh_command_handler::{LidarrRefreshCommand, LidarrRefreshCommandHandler}; use refresh_command_handler::{LidarrRefreshCommand, LidarrRefreshCommandHandler};
use tokio::sync::Mutex; use tokio::sync::Mutex;
use trigger_automatic_search_command_handler::{
LidarrTriggerAutomaticSearchCommand, LidarrTriggerAutomaticSearchCommandHandler,
};
use crate::network::lidarr_network::LidarrEvent; use crate::network::lidarr_network::LidarrEvent;
use crate::{app::App, network::NetworkTrait}; use crate::{app::App, network::NetworkTrait};
@@ -21,6 +24,7 @@ mod edit_command_handler;
mod get_command_handler; mod get_command_handler;
mod list_command_handler; mod list_command_handler;
mod refresh_command_handler; mod refresh_command_handler;
mod trigger_automatic_search_command_handler;
#[cfg(test)] #[cfg(test)]
#[path = "lidarr_command_tests.rs"] #[path = "lidarr_command_tests.rs"]
@@ -58,6 +62,11 @@ pub enum LidarrCommand {
about = "Commands to refresh the data in your Lidarr instance" about = "Commands to refresh the data in your Lidarr instance"
)] )]
Refresh(LidarrRefreshCommand), Refresh(LidarrRefreshCommand),
#[command(
subcommand,
about = "Commands to trigger automatic searches for releases of different resources in your Lidarr instance"
)]
TriggerAutomaticSearch(LidarrTriggerAutomaticSearchCommand),
#[command(about = "Search for a new artist to add to Lidarr")] #[command(about = "Search for a new artist to add to Lidarr")]
SearchNewArtist { SearchNewArtist {
#[arg( #[arg(
@@ -67,6 +76,17 @@ pub enum LidarrCommand {
)] )]
query: String, query: String,
}, },
#[command(
about = "Toggle monitoring for the specified album corresponding to the given album ID"
)]
ToggleAlbumMonitoring {
#[arg(
long,
help = "The Lidarr ID of the album to toggle monitoring on",
required = true
)]
album_id: i64,
},
#[command( #[command(
about = "Toggle monitoring for the specified artist corresponding to the given artist ID" about = "Toggle monitoring for the specified artist corresponding to the given artist ID"
)] )]
@@ -137,6 +157,15 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, LidarrCommand> for LidarrCliHandler<'a, '
.handle() .handle()
.await? .await?
} }
LidarrCommand::TriggerAutomaticSearch(trigger_automatic_search_command) => {
LidarrTriggerAutomaticSearchCommandHandler::with(
self.app,
trigger_automatic_search_command,
self.network,
)
.handle()
.await?
}
LidarrCommand::SearchNewArtist { query } => { LidarrCommand::SearchNewArtist { query } => {
let resp = self let resp = self
.network .network
@@ -144,6 +173,13 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, LidarrCommand> for LidarrCliHandler<'a, '
.await?; .await?;
serde_json::to_string_pretty(&resp)? serde_json::to_string_pretty(&resp)?
} }
LidarrCommand::ToggleAlbumMonitoring { album_id } => {
let resp = self
.network
.handle_network_event(LidarrEvent::ToggleAlbumMonitoring(album_id).into())
.await?;
serde_json::to_string_pretty(&resp)?
}
LidarrCommand::ToggleArtistMonitoring { artist_id } => { LidarrCommand::ToggleArtistMonitoring { artist_id } => {
let resp = self let resp = self
.network .network
+16
View File
@@ -19,6 +19,15 @@ mod refresh_command_handler_tests;
pub enum LidarrRefreshCommand { pub enum LidarrRefreshCommand {
#[command(about = "Refresh all artist data for all artists in your Lidarr library")] #[command(about = "Refresh all artist data for all artists in your Lidarr library")]
AllArtists, AllArtists,
#[command(about = "Refresh artist data and scan disk for the artist with the given ID")]
Artist {
#[arg(
long,
help = "The ID of the artist to refresh information on and to scan the disk for",
required = true
)]
artist_id: i64,
},
} }
impl From<LidarrRefreshCommand> for Command { impl From<LidarrRefreshCommand> for Command {
@@ -57,6 +66,13 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, LidarrRefreshCommand>
.await?; .await?;
serde_json::to_string_pretty(&resp)? serde_json::to_string_pretty(&resp)?
} }
LidarrRefreshCommand::Artist { artist_id } => {
let resp = self
.network
.handle_network_event(LidarrEvent::UpdateAndScanArtist(artist_id).into())
.await?;
serde_json::to_string_pretty(&resp)?
}
}; };
Ok(result) Ok(result)
@@ -20,6 +20,8 @@ mod tests {
mod cli { mod cli {
use super::*; use super::*;
use clap::{Parser, error::ErrorKind};
use pretty_assertions::assert_eq;
#[test] #[test]
fn test_refresh_all_artists_has_no_arg_requirements() { fn test_refresh_all_artists_has_no_arg_requirements() {
@@ -28,6 +30,37 @@ mod tests {
assert_ok!(&result); assert_ok!(&result);
} }
#[test]
fn test_refresh_artist_requires_artist_id() {
let result = Cli::command().try_get_matches_from(["managarr", "lidarr", "refresh", "artist"]);
assert_err!(&result);
assert_eq!(
result.unwrap_err().kind(),
ErrorKind::MissingRequiredArgument
);
}
#[test]
fn test_refresh_artist_with_artist_id() {
let expected_args = LidarrRefreshCommand::Artist { artist_id: 1 };
let result = Cli::try_parse_from([
"managarr",
"lidarr",
"refresh",
"artist",
"--artist-id",
"1",
]);
assert_ok!(&result);
let Some(Command::Lidarr(LidarrCommand::Refresh(refresh_command))) = result.unwrap().command
else {
panic!("Unexpected command type");
};
assert_eq!(refresh_command, expected_args);
}
} }
mod handler { mod handler {
@@ -68,5 +101,29 @@ mod tests {
assert_ok!(&result); assert_ok!(&result);
} }
#[tokio::test]
async fn test_handle_refresh_artist_command() {
let mut mock_network = MockNetworkTrait::new();
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(
LidarrEvent::UpdateAndScanArtist(1).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::Artist { artist_id: 1 };
let result = LidarrRefreshCommandHandler::with(&app_arc, refresh_command, &mut mock_network)
.handle()
.await;
assert_ok!(&result);
}
} }
} }
@@ -0,0 +1,72 @@
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 = "trigger_automatic_search_command_handler_tests.rs"]
mod trigger_automatic_search_command_handler_tests;
#[derive(Debug, Clone, PartialEq, Eq, Subcommand)]
pub enum LidarrTriggerAutomaticSearchCommand {
#[command(about = "Trigger an automatic search for the artist with the specified ID")]
Artist {
#[arg(
long,
help = "The ID of the artist you want to trigger an automatic search for",
required = true
)]
artist_id: i64,
},
}
impl From<LidarrTriggerAutomaticSearchCommand> for Command {
fn from(value: LidarrTriggerAutomaticSearchCommand) -> Self {
Command::Lidarr(LidarrCommand::TriggerAutomaticSearch(value))
}
}
pub(super) struct LidarrTriggerAutomaticSearchCommandHandler<'a, 'b> {
_app: &'a Arc<Mutex<App<'b>>>,
command: LidarrTriggerAutomaticSearchCommand,
network: &'a mut dyn NetworkTrait,
}
impl<'a, 'b> CliCommandHandler<'a, 'b, LidarrTriggerAutomaticSearchCommand>
for LidarrTriggerAutomaticSearchCommandHandler<'a, 'b>
{
fn with(
_app: &'a Arc<Mutex<App<'b>>>,
command: LidarrTriggerAutomaticSearchCommand,
network: &'a mut dyn NetworkTrait,
) -> Self {
LidarrTriggerAutomaticSearchCommandHandler {
_app,
command,
network,
}
}
async fn handle(self) -> Result<String> {
let result = match self.command {
LidarrTriggerAutomaticSearchCommand::Artist { artist_id } => {
let resp = self
.network
.handle_network_event(LidarrEvent::TriggerAutomaticArtistSearch(artist_id).into())
.await?;
serde_json::to_string_pretty(&resp)?
}
};
Ok(result)
}
}
@@ -0,0 +1,107 @@
#[cfg(test)]
mod tests {
use pretty_assertions::assert_eq;
use crate::Cli;
use crate::cli::{
Command,
lidarr::{
LidarrCommand, trigger_automatic_search_command_handler::LidarrTriggerAutomaticSearchCommand,
},
};
use clap::CommandFactory;
#[test]
fn test_lidarr_trigger_automatic_search_command_from() {
let command = LidarrTriggerAutomaticSearchCommand::Artist { artist_id: 1 };
let result = Command::from(command.clone());
assert_eq!(
result,
Command::Lidarr(LidarrCommand::TriggerAutomaticSearch(command))
);
}
mod cli {
use super::*;
use clap::error::ErrorKind;
use pretty_assertions::assert_eq;
#[test]
fn test_trigger_automatic_artist_search_requires_artist_id() {
let result = Cli::command().try_get_matches_from([
"managarr",
"lidarr",
"trigger-automatic-search",
"artist",
]);
assert_err!(&result);
assert_eq!(
result.unwrap_err().kind(),
ErrorKind::MissingRequiredArgument
);
}
#[test]
fn test_trigger_automatic_artist_search_with_artist_id() {
let result = Cli::command().try_get_matches_from([
"managarr",
"lidarr",
"trigger-automatic-search",
"artist",
"--artist-id",
"1",
]);
assert_ok!(&result);
}
}
mod handler {
use std::sync::Arc;
use mockall::predicate::eq;
use serde_json::json;
use tokio::sync::Mutex;
use crate::cli::lidarr::trigger_automatic_search_command_handler::{
LidarrTriggerAutomaticSearchCommand, LidarrTriggerAutomaticSearchCommandHandler,
};
use crate::{app::App, cli::CliCommandHandler};
use crate::{
models::{Serdeable, lidarr_models::LidarrSerdeable},
network::{MockNetworkTrait, NetworkEvent, lidarr_network::LidarrEvent},
};
#[tokio::test]
async fn test_handle_trigger_automatic_artist_search_command() {
let mut mock_network = MockNetworkTrait::new();
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(
LidarrEvent::TriggerAutomaticArtistSearch(1).into(),
))
.times(1)
.returning(|_| {
Ok(Serdeable::Lidarr(LidarrSerdeable::Value(
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::test_default()));
let trigger_automatic_search_command =
LidarrTriggerAutomaticSearchCommand::Artist { artist_id: 1 };
let result = LidarrTriggerAutomaticSearchCommandHandler::with(
&app_arc,
trigger_automatic_search_command,
&mut mock_network,
)
.handle()
.await;
assert_ok!(&result);
}
}
}
@@ -0,0 +1,211 @@
use crate::app::App;
use crate::event::Key;
use crate::handlers::table_handler::{TableHandlingConfig, handle_table};
use crate::handlers::{KeyEventHandler, handle_prompt_toggle};
use crate::matches_key;
use crate::models::lidarr_models::Album;
use crate::models::servarr_data::lidarr::lidarr_data::{
ARTIST_DETAILS_BLOCKS, ActiveLidarrBlock, EDIT_ARTIST_SELECTION_BLOCKS,
};
use crate::models::{BlockSelectionState, Route};
use crate::network::lidarr_network::LidarrEvent;
#[cfg(test)]
#[path = "artist_details_handler_tests.rs"]
mod artist_details_handler_tests;
pub struct ArtistDetailsHandler<'a, 'b> {
key: Key,
app: &'a mut App<'b>,
active_lidarr_block: ActiveLidarrBlock,
_context: Option<ActiveLidarrBlock>,
}
impl ArtistDetailsHandler<'_, '_> {
fn extract_artist_id(&self) -> i64 {
self.app.data.lidarr_data.artists.current_selection().id
}
fn extract_album_id(&self) -> i64 {
self.app.data.lidarr_data.albums.current_selection().id
}
}
impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveLidarrBlock> for ArtistDetailsHandler<'a, 'b> {
fn handle(&mut self) {
let albums_table_handling_config =
TableHandlingConfig::new(ActiveLidarrBlock::ArtistDetails.into())
.searching_block(ActiveLidarrBlock::SearchAlbums.into())
.search_error_block(ActiveLidarrBlock::SearchAlbumsError.into())
.search_field_fn(|album: &Album| &album.title.text);
if !handle_table(
self,
|app| &mut app.data.lidarr_data.albums,
albums_table_handling_config,
) {
self.handle_key_event();
}
}
fn accepts(active_block: ActiveLidarrBlock) -> bool {
ARTIST_DETAILS_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<ActiveLidarrBlock>,
) -> ArtistDetailsHandler<'a, 'b> {
ArtistDetailsHandler {
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) {}
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) {
match self.active_lidarr_block {
ActiveLidarrBlock::UpdateAndScanArtistPrompt
| ActiveLidarrBlock::AutomaticallySearchArtistPrompt => {
handle_prompt_toggle(self.app, self.key);
}
_ => (),
}
}
fn handle_submit(&mut self) {
match self.active_lidarr_block {
ActiveLidarrBlock::AutomaticallySearchArtistPrompt => {
if self.app.data.lidarr_data.prompt_confirm {
self.app.data.lidarr_data.prompt_confirm_action = Some(
LidarrEvent::TriggerAutomaticArtistSearch(self.extract_artist_id()),
);
}
self.app.pop_navigation_stack();
}
ActiveLidarrBlock::UpdateAndScanArtistPrompt => {
if self.app.data.lidarr_data.prompt_confirm {
self.app.data.lidarr_data.prompt_confirm_action =
Some(LidarrEvent::UpdateAndScanArtist(self.extract_artist_id()));
}
self.app.pop_navigation_stack();
}
_ => (),
}
}
fn handle_esc(&mut self) {
match self.active_lidarr_block {
ActiveLidarrBlock::UpdateAndScanArtistPrompt
| ActiveLidarrBlock::AutomaticallySearchArtistPrompt => {
self.app.pop_navigation_stack();
self.app.data.lidarr_data.prompt_confirm = false;
}
ActiveLidarrBlock::ArtistDetails => {
self.app.pop_navigation_stack();
self.app.data.lidarr_data.reset_artist_info_tabs();
}
_ => (),
}
}
fn handle_char_key_event(&mut self) {
let key = self.key;
match self.active_lidarr_block {
ActiveLidarrBlock::ArtistDetails => match self.key {
_ if matches_key!(refresh, key) => self
.app
.pop_and_push_navigation_stack(self.active_lidarr_block.into()),
_ if matches_key!(auto_search, key) => {
self
.app
.push_navigation_stack(ActiveLidarrBlock::AutomaticallySearchArtistPrompt.into());
}
_ if matches_key!(update, key) => {
self
.app
.push_navigation_stack(ActiveLidarrBlock::UpdateAndScanArtistPrompt.into());
}
_ if matches_key!(edit, key) => {
self.app.push_navigation_stack(
(
ActiveLidarrBlock::EditArtistPrompt,
Some(self.active_lidarr_block),
)
.into(),
);
self.app.data.lidarr_data.edit_artist_modal = Some((&self.app.data.lidarr_data).into());
self.app.data.lidarr_data.selected_block =
BlockSelectionState::new(EDIT_ARTIST_SELECTION_BLOCKS);
}
_ if matches_key!(toggle_monitoring, key) => {
if !self.app.data.lidarr_data.albums.is_empty() {
self.app.data.lidarr_data.prompt_confirm = true;
self.app.data.lidarr_data.prompt_confirm_action =
Some(LidarrEvent::ToggleAlbumMonitoring(self.extract_album_id()));
self
.app
.pop_and_push_navigation_stack(self.active_lidarr_block.into());
}
}
_ => (),
},
ActiveLidarrBlock::AutomaticallySearchArtistPrompt => {
if matches_key!(confirm, key) {
self.app.data.lidarr_data.prompt_confirm = true;
self.app.data.lidarr_data.prompt_confirm_action = Some(
LidarrEvent::TriggerAutomaticArtistSearch(self.extract_artist_id()),
);
self.app.pop_navigation_stack();
}
}
ActiveLidarrBlock::UpdateAndScanArtistPrompt => {
if matches_key!(confirm, key) {
self.app.data.lidarr_data.prompt_confirm = true;
self.app.data.lidarr_data.prompt_confirm_action =
Some(LidarrEvent::UpdateAndScanArtist(self.extract_artist_id()));
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()
}
}
@@ -0,0 +1,533 @@
#[cfg(test)]
mod tests {
use pretty_assertions::assert_eq;
use rstest::rstest;
use strum::IntoEnumIterator;
use crate::app::key_binding::DEFAULT_KEYBINDINGS;
use crate::app::App;
use crate::handlers::lidarr_handlers::library::artist_details_handler::ArtistDetailsHandler;
use crate::handlers::KeyEventHandler;
use crate::models::servarr_data::lidarr::lidarr_data::{
ActiveLidarrBlock, ARTIST_DETAILS_BLOCKS,
};
mod test_handle_left_right_action {
use rstest::rstest;
use crate::app::App;
use crate::event::Key;
use crate::handlers::lidarr_handlers::library::artist_details_handler::ArtistDetailsHandler;
use crate::handlers::KeyEventHandler;
use crate::models::servarr_data::lidarr::lidarr_data::ActiveLidarrBlock;
#[rstest]
fn test_left_right_prompt_toggle(
#[values(
ActiveLidarrBlock::UpdateAndScanArtistPrompt,
ActiveLidarrBlock::AutomaticallySearchArtistPrompt,
)] active_lidarr_block: ActiveLidarrBlock,
#[values(Key::Left, Key::Right)] key: Key,
) {
let mut app = App::test_default();
app.push_navigation_stack(ActiveLidarrBlock::ArtistDetails.into());
app.push_navigation_stack(active_lidarr_block.into());
ArtistDetailsHandler::new(
key,
&mut app,
active_lidarr_block,
None,
)
.handle();
assert!(app.data.lidarr_data.prompt_confirm);
ArtistDetailsHandler::new(
key,
&mut app,
active_lidarr_block,
None,
)
.handle();
assert!(!app.data.lidarr_data.prompt_confirm);
}
}
mod test_handle_submit {
use rstest::rstest;
use crate::app::key_binding::DEFAULT_KEYBINDINGS;
use crate::app::App;
use crate::assert_navigation_popped;
use crate::event::Key;
use crate::handlers::lidarr_handlers::library::artist_details_handler::ArtistDetailsHandler;
use crate::handlers::KeyEventHandler;
use crate::models::servarr_data::lidarr::lidarr_data::ActiveLidarrBlock;
use crate::network::lidarr_network::lidarr_network_test_utils::test_utils::artist;
use crate::network::lidarr_network::LidarrEvent;
const SUBMIT_KEY: Key = DEFAULT_KEYBINDINGS.submit.key;
#[rstest]
#[case(ActiveLidarrBlock::AutomaticallySearchArtistPrompt, LidarrEvent::TriggerAutomaticArtistSearch(1))]
#[case(ActiveLidarrBlock::UpdateAndScanArtistPrompt, LidarrEvent::UpdateAndScanArtist(1))]
fn test_artist_details_prompt_confirm_submit(
#[case] prompt_block: ActiveLidarrBlock,
#[case] expected_action: LidarrEvent
) {
let mut app = App::test_default();
app.data.lidarr_data.prompt_confirm = true;
app.data.lidarr_data.artists.set_items(vec![artist()]);
app.push_navigation_stack(ActiveLidarrBlock::ArtistDetails.into());
app.push_navigation_stack(prompt_block.into());
ArtistDetailsHandler::new(
SUBMIT_KEY,
&mut app,
prompt_block,
None,
)
.handle();
assert!(app.data.lidarr_data.prompt_confirm);
assert_navigation_popped!(app, ActiveLidarrBlock::ArtistDetails.into());
assert_some_eq_x!(
&app.data.lidarr_data.prompt_confirm_action,
&expected_action
);
}
#[rstest]
fn test_artist_details_prompt_decline_submit(
#[values(
ActiveLidarrBlock::AutomaticallySearchArtistPrompt,
ActiveLidarrBlock::UpdateAndScanArtistPrompt,
)] prompt_block: ActiveLidarrBlock
) {
let mut app = App::test_default();
app.push_navigation_stack(ActiveLidarrBlock::ArtistDetails.into());
app.push_navigation_stack(prompt_block.into());
ArtistDetailsHandler::new(
SUBMIT_KEY,
&mut app,
prompt_block,
None,
)
.handle();
assert!(!app.data.lidarr_data.prompt_confirm);
assert_navigation_popped!(app, ActiveLidarrBlock::ArtistDetails.into());
assert_none!(app.data.lidarr_data.prompt_confirm_action);
}
}
mod test_handle_esc {
use rstest::rstest;
use crate::app::key_binding::DEFAULT_KEYBINDINGS;
use crate::app::App;
use crate::assert_navigation_popped;
use crate::event::Key;
use crate::handlers::lidarr_handlers::library::artist_details_handler::ArtistDetailsHandler;
use crate::handlers::KeyEventHandler;
use crate::models::servarr_data::lidarr::lidarr_data::ActiveLidarrBlock;
const ESC_KEY: Key = DEFAULT_KEYBINDINGS.esc.key;
#[rstest]
fn test_artist_details_esc(
#[values(
ActiveLidarrBlock::AutomaticallySearchArtistPrompt,
ActiveLidarrBlock::UpdateAndScanArtistPrompt
)] prompt_block: ActiveLidarrBlock,
#[values(true, false)] is_ready: bool
) {
let mut app = App::test_default();
app.is_loading = is_ready;
app.data.lidarr_data.prompt_confirm = true;
app.push_navigation_stack(ActiveLidarrBlock::ArtistDetails.into());
app.push_navigation_stack(prompt_block.into());
ArtistDetailsHandler::new(ESC_KEY, &mut app, prompt_block, None).handle();
assert!(!app.data.lidarr_data.prompt_confirm);
assert_navigation_popped!(app, ActiveLidarrBlock::ArtistDetails.into());
}
}
mod test_handle_char_key_event {
use pretty_assertions::assert_eq;
use rstest::rstest;
use crate::app::key_binding::DEFAULT_KEYBINDINGS;
use crate::app::App;
use crate::{assert_modal_absent, assert_modal_present, assert_navigation_popped};
use crate::assert_navigation_pushed;
use crate::handlers::lidarr_handlers::library::artist_details_handler::ArtistDetailsHandler;
use crate::handlers::KeyEventHandler;
use crate::models::lidarr_models::{Artist};
use crate::models::servarr_data::lidarr::lidarr_data::{ActiveLidarrBlock, EDIT_ARTIST_SELECTION_BLOCKS};
use crate::network::lidarr_network::LidarrEvent;
#[rstest]
fn test_artist_details_edit_key(
#[values(ActiveLidarrBlock::ArtistDetails)] active_lidarr_block: ActiveLidarrBlock
) {
let mut app = App::test_default_fully_populated();
app.push_navigation_stack(ActiveLidarrBlock::ArtistDetails.into());
app.push_navigation_stack(active_lidarr_block.into());
ArtistDetailsHandler::new(
DEFAULT_KEYBINDINGS.edit.key,
&mut app,
active_lidarr_block,
None,
)
.handle();
assert_navigation_pushed!(
app,
(
ActiveLidarrBlock::EditArtistPrompt,
Some(ActiveLidarrBlock::ArtistDetails)
)
.into()
);
assert_modal_present!(app.data.lidarr_data.edit_artist_modal);
assert!(app.data.lidarr_data.edit_artist_modal.is_some());
assert_eq!(
app.data.lidarr_data.selected_block.blocks,
EDIT_ARTIST_SELECTION_BLOCKS
);
}
#[rstest]
fn test_artist_details_edit_key_no_op_when_not_ready(
#[values(ActiveLidarrBlock::ArtistDetails)] active_lidarr_block: ActiveLidarrBlock
) {
let mut app = App::test_default();
app.is_loading = true;
app.push_navigation_stack(ActiveLidarrBlock::ArtistDetails.into());
app.push_navigation_stack(active_lidarr_block.into());
ArtistDetailsHandler::new(
DEFAULT_KEYBINDINGS.edit.key,
&mut app,
active_lidarr_block,
None,
)
.handle();
assert_eq!(app.get_current_route(), active_lidarr_block.into());
assert_modal_absent!(app.data.lidarr_data.edit_artist_modal);
}
#[test]
fn test_artist_details_toggle_monitoring_key() {
let mut app = App::test_default_fully_populated();
app.is_routing = false;
app.push_navigation_stack(ActiveLidarrBlock::Artists.into());
app.push_navigation_stack(ActiveLidarrBlock::ArtistDetails.into());
ArtistDetailsHandler::new(
DEFAULT_KEYBINDINGS.toggle_monitoring.key,
&mut app,
ActiveLidarrBlock::ArtistDetails,
None,
)
.handle();
assert_eq!(app.get_current_route(), ActiveLidarrBlock::ArtistDetails.into());
assert!(app.data.lidarr_data.prompt_confirm);
assert!(app.is_routing);
assert_eq!(
app.data.lidarr_data.prompt_confirm_action,
Some(LidarrEvent::ToggleAlbumMonitoring(1))
);
}
#[test]
fn test_artist_details_toggle_monitoring_key_no_op_when_not_ready() {
let mut app = App::test_default();
app.is_loading = true;
app.data.lidarr_data.prompt_confirm = false;
app.push_navigation_stack(ActiveLidarrBlock::ArtistDetails.into());
ArtistDetailsHandler::new(
DEFAULT_KEYBINDINGS.toggle_monitoring.key,
&mut app,
ActiveLidarrBlock::ArtistDetails,
None,
)
.handle();
assert_eq!(app.get_current_route(), ActiveLidarrBlock::ArtistDetails.into());
assert!(!app.data.lidarr_data.prompt_confirm);
assert_none!(app.data.lidarr_data.prompt_confirm_action);
}
#[test]
fn test_artist_details_toggle_monitoring_key_no_op_when_albums_empty() {
let mut app = App::test_default();
app.data.lidarr_data.artists.set_items(vec![Artist {
id: 1,
..Artist::default()
}]);
app.push_navigation_stack(ActiveLidarrBlock::ArtistDetails.into());
ArtistDetailsHandler::new(
DEFAULT_KEYBINDINGS.toggle_monitoring.key,
&mut app,
ActiveLidarrBlock::ArtistDetails,
None,
)
.handle();
assert!(!app.data.lidarr_data.prompt_confirm);
assert!(app.data.lidarr_data.prompt_confirm_action.is_none());
}
#[rstest]
fn test_artist_details_auto_search_key(
#[values(ActiveLidarrBlock::ArtistDetails)] active_lidarr_block: ActiveLidarrBlock
) {
let mut app = App::test_default_fully_populated();
app.push_navigation_stack(active_lidarr_block.into());
ArtistDetailsHandler::new(
DEFAULT_KEYBINDINGS.auto_search.key,
&mut app,
active_lidarr_block,
None,
)
.handle();
assert_navigation_pushed!(
app,
ActiveLidarrBlock::AutomaticallySearchArtistPrompt.into()
);
}
#[rstest]
fn test_artist_details_auto_search_key_no_op_when_not_ready(
#[values(ActiveLidarrBlock::ArtistDetails)] active_lidarr_block: ActiveLidarrBlock
) {
let mut app = App::test_default_fully_populated();
app.is_loading = true;
app.push_navigation_stack(active_lidarr_block.into());
ArtistDetailsHandler::new(
DEFAULT_KEYBINDINGS.auto_search.key,
&mut app,
active_lidarr_block,
None,
)
.handle();
assert_eq!(app.get_current_route(), active_lidarr_block.into());
}
#[rstest]
fn test_artist_details_update_key(
#[values(ActiveLidarrBlock::ArtistDetails)] active_lidarr_block: ActiveLidarrBlock
) {
let mut app = App::test_default_fully_populated();
app.push_navigation_stack(active_lidarr_block.into());
ArtistDetailsHandler::new(
DEFAULT_KEYBINDINGS.update.key,
&mut app,
active_lidarr_block,
None,
)
.handle();
assert_navigation_pushed!(app, ActiveLidarrBlock::UpdateAndScanArtistPrompt.into());
}
#[rstest]
fn test_artist_details_update_key_no_op_when_not_ready(
#[values(ActiveLidarrBlock::ArtistDetails)] active_lidarr_block: ActiveLidarrBlock
) {
let mut app = App::test_default_fully_populated();
app.is_loading = true;
app.push_navigation_stack(active_lidarr_block.into());
ArtistDetailsHandler::new(
DEFAULT_KEYBINDINGS.update.key,
&mut app,
active_lidarr_block,
None,
)
.handle();
assert_eq!(app.get_current_route(), active_lidarr_block.into());
}
#[rstest]
fn test_artist_details_refresh_key(
#[values(ActiveLidarrBlock::ArtistDetails)] active_lidarr_block: ActiveLidarrBlock
) {
let mut app = App::test_default_fully_populated();
app.is_routing = false;
app.push_navigation_stack(ActiveLidarrBlock::Artists.into());
app.push_navigation_stack(active_lidarr_block.into());
ArtistDetailsHandler::new(
DEFAULT_KEYBINDINGS.refresh.key,
&mut app,
active_lidarr_block,
None,
)
.handle();
assert_navigation_pushed!(
app,
active_lidarr_block.into()
);
assert!(app.is_routing);
}
#[rstest]
fn test_artist_details_refresh_key_no_op_when_not_ready(
#[values(ActiveLidarrBlock::ArtistDetails)] active_lidarr_block: ActiveLidarrBlock
) {
let mut app = App::test_default();
app.is_loading = true;
app.push_navigation_stack(active_lidarr_block.into());
app.is_routing = false;
ArtistDetailsHandler::new(
DEFAULT_KEYBINDINGS.refresh.key,
&mut app,
active_lidarr_block,
None,
)
.handle();
assert_eq!(
app.get_current_route(),
active_lidarr_block.into()
);
assert!(!app.is_routing);
}
#[rstest]
#[case(
ActiveLidarrBlock::AutomaticallySearchArtistPrompt,
LidarrEvent::TriggerAutomaticArtistSearch(1)
)]
#[case(
ActiveLidarrBlock::UpdateAndScanArtistPrompt,
LidarrEvent::UpdateAndScanArtist(1)
)]
fn test_artist_details_prompt_confirm_key(
#[case] prompt_block: ActiveLidarrBlock,
#[case] expected_action: LidarrEvent,
#[values(ActiveLidarrBlock::ArtistDetails)]
active_lidarr_block: ActiveLidarrBlock,
) {
let mut app = App::test_default_fully_populated();
app.push_navigation_stack(active_lidarr_block.into());
app.push_navigation_stack(prompt_block.into());
ArtistDetailsHandler::new(
DEFAULT_KEYBINDINGS.confirm.key,
&mut app,
prompt_block,
None,
)
.handle();
assert!(app.data.lidarr_data.prompt_confirm);
assert_navigation_popped!(app, active_lidarr_block.into());
assert_some_eq_x!(
&app.data.lidarr_data.prompt_confirm_action,
&expected_action
);
}
}
#[test]
fn test_artist_details_handler_accepts() {
ActiveLidarrBlock::iter().for_each(|active_lidarr_block| {
if ARTIST_DETAILS_BLOCKS.contains(&active_lidarr_block) {
assert!(ArtistDetailsHandler::accepts(active_lidarr_block));
} else {
assert!(!ArtistDetailsHandler::accepts(active_lidarr_block));
}
});
}
#[test]
fn test_extract_artist_id() {
let mut app = App::test_default_fully_populated();
let artist_id = ArtistDetailsHandler::new(DEFAULT_KEYBINDINGS.esc.key,
&mut app, ActiveLidarrBlock::ArtistDetails, None).extract_artist_id();
assert_eq!(artist_id, 1);
}
#[test]
fn test_extract_album_id() {
let mut app = App::test_default_fully_populated();
let album_id = ArtistDetailsHandler::new(DEFAULT_KEYBINDINGS.esc.key,
&mut app, ActiveLidarrBlock::ArtistDetails, None).extract_album_id();
assert_eq!(album_id, 1);
}
#[rstest]
fn test_artist_details_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 = ArtistDetailsHandler::new(
DEFAULT_KEYBINDINGS.esc.key,
&mut app,
ActiveLidarrBlock::ArtistDetails,
None,
);
assert_eq!(
handler.ignore_special_keys(),
ignore_special_keys_for_textbox_input
);
}
#[test]
fn test_artist_details_handler_is_not_ready_when_loading() {
let mut app = App::test_default();
app.push_navigation_stack(ActiveLidarrBlock::ArtistDetails.into());
app.is_loading = true;
let handler = ArtistDetailsHandler::new(
DEFAULT_KEYBINDINGS.esc.key,
&mut app,
ActiveLidarrBlock::ArtistDetails,
None,
);
assert!(!handler.is_ready());
}
#[test]
fn test_artist_details_handler_is_ready_when_not_loading() {
let mut app = App::test_default();
app.push_navigation_stack(ActiveLidarrBlock::ArtistDetails.into());
app.is_loading = false;
let handler = ArtistDetailsHandler::new(
DEFAULT_KEYBINDINGS.esc.key,
&mut app,
ActiveLidarrBlock::ArtistDetails,
None,
);
assert!(handler.is_ready());
}
}
@@ -13,8 +13,8 @@ mod tests {
use crate::handlers::lidarr_handlers::library::{LibraryHandler, artists_sorting_options}; use crate::handlers::lidarr_handlers::library::{LibraryHandler, artists_sorting_options};
use crate::models::lidarr_models::{Artist, ArtistStatistics, ArtistStatus}; use crate::models::lidarr_models::{Artist, ArtistStatistics, ArtistStatus};
use crate::models::servarr_data::lidarr::lidarr_data::{ use crate::models::servarr_data::lidarr::lidarr_data::{
ADD_ARTIST_BLOCKS, ActiveLidarrBlock, DELETE_ARTIST_BLOCKS, EDIT_ARTIST_BLOCKS, ADD_ARTIST_BLOCKS, ARTIST_DETAILS_BLOCKS, ActiveLidarrBlock, DELETE_ARTIST_BLOCKS,
EDIT_ARTIST_SELECTION_BLOCKS, LIBRARY_BLOCKS, EDIT_ARTIST_BLOCKS, EDIT_ARTIST_SELECTION_BLOCKS, LIBRARY_BLOCKS,
}; };
use crate::models::servarr_data::lidarr::modals::EditArtistModal; use crate::models::servarr_data::lidarr::modals::EditArtistModal;
use crate::network::lidarr_network::LidarrEvent; use crate::network::lidarr_network::LidarrEvent;
@@ -26,6 +26,7 @@ mod tests {
fn test_library_handler_accepts() { fn test_library_handler_accepts() {
let mut library_handler_blocks = Vec::new(); let mut library_handler_blocks = Vec::new();
library_handler_blocks.extend(LIBRARY_BLOCKS); library_handler_blocks.extend(LIBRARY_BLOCKS);
library_handler_blocks.extend(ARTIST_DETAILS_BLOCKS);
library_handler_blocks.extend(DELETE_ARTIST_BLOCKS); library_handler_blocks.extend(DELETE_ARTIST_BLOCKS);
library_handler_blocks.extend(EDIT_ARTIST_BLOCKS); library_handler_blocks.extend(EDIT_ARTIST_BLOCKS);
library_handler_blocks.extend(ADD_ARTIST_BLOCKS); library_handler_blocks.extend(ADD_ARTIST_BLOCKS);
@@ -587,6 +588,32 @@ mod tests {
assert_eq!(app.get_current_route(), ActiveLidarrBlock::Artists.into()); assert_eq!(app.get_current_route(), ActiveLidarrBlock::Artists.into());
} }
#[rstest]
fn test_delegates_artist_details_blocks_to_artist_details_handler(
#[values(
ActiveLidarrBlock::ArtistDetails,
ActiveLidarrBlock::AutomaticallySearchArtistPrompt,
ActiveLidarrBlock::SearchAlbums,
ActiveLidarrBlock::SearchAlbumsError,
ActiveLidarrBlock::UpdateAndScanArtistPrompt
)]
active_lidarr_block: ActiveLidarrBlock,
) {
let mut app = App::test_default_fully_populated();
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] #[test]
fn test_edit_key() { fn test_edit_key() {
let mut app = App::test_default(); let mut app = App::test_default();
+19 -4
View File
@@ -19,11 +19,13 @@ use super::handle_change_tab_left_right_keys;
use crate::handlers::table_handler::{TableHandlingConfig, handle_table}; use crate::handlers::table_handler::{TableHandlingConfig, handle_table};
mod add_artist_handler; mod add_artist_handler;
mod artist_details_handler;
mod delete_artist_handler; mod delete_artist_handler;
mod edit_artist_handler; mod edit_artist_handler;
use crate::models::Route; use crate::models::Route;
pub(in crate::handlers::lidarr_handlers) use add_artist_handler::AddArtistHandler; pub(in crate::handlers::lidarr_handlers) use add_artist_handler::AddArtistHandler;
pub(in crate::handlers::lidarr_handlers) use artist_details_handler::ArtistDetailsHandler;
pub(in crate::handlers::lidarr_handlers) use delete_artist_handler::DeleteArtistHandler; pub(in crate::handlers::lidarr_handlers) use delete_artist_handler::DeleteArtistHandler;
pub(in crate::handlers::lidarr_handlers) use edit_artist_handler::EditArtistHandler; pub(in crate::handlers::lidarr_handlers) use edit_artist_handler::EditArtistHandler;
@@ -74,6 +76,10 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveLidarrBlock> for LibraryHandler<'a, '
EditArtistHandler::new(self.key, self.app, self.active_lidarr_block, self.context) EditArtistHandler::new(self.key, self.app, self.active_lidarr_block, self.context)
.handle(); .handle();
} }
_ if ArtistDetailsHandler::accepts(self.active_lidarr_block) => {
ArtistDetailsHandler::new(self.key, self.app, self.active_lidarr_block, self.context)
.handle();
}
_ => self.handle_key_event(), _ => self.handle_key_event(),
} }
} }
@@ -83,6 +89,7 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveLidarrBlock> for LibraryHandler<'a, '
AddArtistHandler::accepts(active_block) AddArtistHandler::accepts(active_block)
|| DeleteArtistHandler::accepts(active_block) || DeleteArtistHandler::accepts(active_block)
|| EditArtistHandler::accepts(active_block) || EditArtistHandler::accepts(active_block)
|| ArtistDetailsHandler::accepts(active_block)
|| LIBRARY_BLOCKS.contains(&active_block) || LIBRARY_BLOCKS.contains(&active_block)
} }
@@ -139,12 +146,20 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveLidarrBlock> for LibraryHandler<'a, '
} }
fn handle_submit(&mut self) { fn handle_submit(&mut self) {
if self.active_lidarr_block == ActiveLidarrBlock::UpdateAllArtistsPrompt { match self.active_lidarr_block {
if self.app.data.lidarr_data.prompt_confirm { ActiveLidarrBlock::Artists => {
self.app.data.lidarr_data.prompt_confirm_action = Some(LidarrEvent::UpdateAllArtists); self
.app
.push_navigation_stack(ActiveLidarrBlock::ArtistDetails.into());
} }
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(); self.app.pop_navigation_stack();
}
_ => (),
} }
} }
@@ -402,7 +402,7 @@ mod tests {
ActiveSonarrBlock::SeriesDetails.into() ActiveSonarrBlock::SeriesDetails.into()
); );
assert!(!app.data.sonarr_data.prompt_confirm); assert!(!app.data.sonarr_data.prompt_confirm);
assert_modal_absent!(app.data.sonarr_data.prompt_confirm_action); assert_none!(app.data.sonarr_data.prompt_confirm_action);
assert!(!app.is_routing); assert!(!app.is_routing);
} }
+48
View File
@@ -245,6 +245,14 @@ pub struct AddArtistSearchResult {
pub ratings: Option<Ratings>, pub ratings: Option<Ratings>,
} }
#[derive(Default, Serialize, Debug)]
#[serde(rename_all = "camelCase")]
pub struct LidarrCommandBody {
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub artist_id: Option<i64>,
}
#[derive(Serialize, Deserialize, Debug, Default, Clone, Eq, PartialEq)] #[derive(Serialize, Deserialize, Debug, Default, Clone, Eq, PartialEq)]
#[serde(rename_all = "lowercase")] #[serde(rename_all = "lowercase")]
pub struct DeleteArtistParams { pub struct DeleteArtistParams {
@@ -291,6 +299,44 @@ pub struct EditArtistParams {
pub clear_tags: bool, pub clear_tags: bool,
} }
#[derive(Derivative, Serialize, Deserialize, Debug, Default, Clone, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct Album {
#[serde(deserialize_with = "super::from_i64")]
pub id: i64,
pub title: HorizontallyScrollableText,
pub foreign_album_id: String,
pub monitored: bool,
#[serde(default)]
pub any_release_ok: bool,
#[serde(deserialize_with = "super::from_i64")]
pub profile_id: i64,
#[serde(deserialize_with = "super::from_i64")]
pub duration: i64,
pub album_type: Option<String>,
pub genres: Vec<String>,
pub ratings: Option<Ratings>,
pub release_date: Option<DateTime<Utc>>,
pub statistics: Option<AlbumStatistics>,
}
#[derive(Derivative, Serialize, Deserialize, Debug, Default, Clone, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct AlbumStatistics {
#[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 AlbumStatistics {}
impl From<LidarrSerdeable> for Serdeable { impl From<LidarrSerdeable> for Serdeable {
fn from(value: LidarrSerdeable) -> Serdeable { fn from(value: LidarrSerdeable) -> Serdeable {
Serdeable::Lidarr(value) Serdeable::Lidarr(value)
@@ -300,6 +346,8 @@ impl From<LidarrSerdeable> for Serdeable {
serde_enum_from!( serde_enum_from!(
LidarrSerdeable { LidarrSerdeable {
AddArtistSearchResults(Vec<AddArtistSearchResult>), AddArtistSearchResults(Vec<AddArtistSearchResult>),
Albums(Vec<Album>),
Album(Album),
Artist(Artist), Artist(Artist),
Artists(Vec<Artist>), Artists(Vec<Artist>),
DiskSpaces(Vec<DiskSpace>), DiskSpaces(Vec<DiskSpace>),
+40 -16
View File
@@ -5,7 +5,7 @@ mod tests {
use serde_json::json; use serde_json::json;
use crate::models::lidarr_models::{ use crate::models::lidarr_models::{
AddArtistSearchResult, DownloadRecord, DownloadStatus, DownloadsResponse, Member, AddArtistSearchResult, Album, DownloadRecord, DownloadStatus, DownloadsResponse, Member,
MetadataProfile, MonitorType, NewItemMonitorType, SystemStatus, MetadataProfile, MonitorType, NewItemMonitorType, SystemStatus,
}; };
use crate::models::servarr_models::{ use crate::models::servarr_models::{
@@ -401,6 +401,45 @@ mod tests {
assert_eq!(lidarr_serdeable, LidarrSerdeable::Tags(tags)); assert_eq!(lidarr_serdeable, LidarrSerdeable::Tags(tags));
} }
#[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)
);
}
#[test]
fn test_lidarr_serdeable_from_albums() {
let albums = vec![Album {
id: 1,
..Album::default()
}];
let lidarr_serdeable: LidarrSerdeable = albums.clone().into();
assert_eq!(lidarr_serdeable, LidarrSerdeable::Albums(albums));
}
#[test]
fn test_lidarr_serdeable_from_album() {
let album = Album {
id: 1,
..Album::default()
};
let lidarr_serdeable: LidarrSerdeable = album.clone().into();
assert_eq!(lidarr_serdeable, LidarrSerdeable::Album(album));
}
#[test] #[test]
fn test_artist_status_display() { fn test_artist_status_display() {
assert_str_eq!(ArtistStatus::Continuing.to_string(), "continuing"); assert_str_eq!(ArtistStatus::Continuing.to_string(), "continuing");
@@ -501,19 +540,4 @@ mod tests {
assert!(search_result.genres.is_empty()); assert!(search_result.genres.is_empty());
assert_none!(&search_result.ratings); 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)
);
}
} }
+36 -5
View File
@@ -1,10 +1,12 @@
use serde_json::Number; use serde_json::Number;
use super::modals::{AddArtistModal, EditArtistModal}; use super::modals::{AddArtistModal, EditArtistModal};
use crate::app::lidarr::lidarr_context_clues::ARTISTS_CONTEXT_CLUES; use crate::app::lidarr::lidarr_context_clues::{
ARTIST_DETAILS_CONTEXT_CLUES, ARTISTS_CONTEXT_CLUES,
};
use crate::models::{ use crate::models::{
BlockSelectionState, HorizontallyScrollableText, Route, TabRoute, TabState, BlockSelectionState, HorizontallyScrollableText, Route, TabRoute, TabState,
lidarr_models::{AddArtistSearchResult, Artist, DownloadRecord}, lidarr_models::{AddArtistSearchResult, Album, Artist, DownloadRecord},
servarr_models::{DiskSpace, RootFolder}, servarr_models::{DiskSpace, RootFolder},
stateful_table::StatefulTable, stateful_table::StatefulTable,
}; };
@@ -19,8 +21,8 @@ use {
crate::models::stateful_table::SortOption, 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::quality_profile_map,
crate::network::lidarr_network::lidarr_network_test_utils::test_utils::{ crate::network::lidarr_network::lidarr_network_test_utils::test_utils::{
add_artist_search_result, download_record, metadata_profile, metadata_profile_map, add_artist_search_result, album, artist, download_record, metadata_profile,
quality_profile, root_folder, tags_map, metadata_profile_map, quality_profile, root_folder, tags_map,
}, },
crate::network::servarr_test_utils::diskspace, crate::network::servarr_test_utils::diskspace,
strum::{Display, EnumString, IntoEnumIterator}, strum::{Display, EnumString, IntoEnumIterator},
@@ -35,6 +37,8 @@ pub struct LidarrData<'a> {
pub add_artist_search: Option<HorizontallyScrollableText>, pub add_artist_search: Option<HorizontallyScrollableText>,
pub add_import_list_exclusion: bool, pub add_import_list_exclusion: bool,
pub add_searched_artists: Option<StatefulTable<AddArtistSearchResult>>, pub add_searched_artists: Option<StatefulTable<AddArtistSearchResult>>,
pub albums: StatefulTable<Album>,
pub artist_info_tabs: TabState,
pub artists: StatefulTable<Artist>, pub artists: StatefulTable<Artist>,
pub delete_artist_files: bool, pub delete_artist_files: bool,
pub disk_space_vec: Vec<DiskSpace>, pub disk_space_vec: Vec<DiskSpace>,
@@ -58,6 +62,11 @@ impl LidarrData<'_> {
self.add_import_list_exclusion = false; self.add_import_list_exclusion = false;
} }
pub fn reset_artist_info_tabs(&mut self) {
self.albums = StatefulTable::default();
self.artist_info_tabs.index = 0;
}
pub fn tag_ids_to_display(&self, tag_ids: &[Number]) -> String { pub fn tag_ids_to_display(&self, tag_ids: &[Number]) -> String {
tag_ids tag_ids
.iter() .iter()
@@ -97,6 +106,7 @@ impl<'a> Default for LidarrData<'a> {
add_artist_search: None, add_artist_search: None,
add_import_list_exclusion: false, add_import_list_exclusion: false,
add_searched_artists: None, add_searched_artists: None,
albums: StatefulTable::default(),
artists: StatefulTable::default(), artists: StatefulTable::default(),
delete_artist_files: false, delete_artist_files: false,
disk_space_vec: Vec::new(), disk_space_vec: Vec::new(),
@@ -117,6 +127,12 @@ impl<'a> Default for LidarrData<'a> {
contextual_help: Some(&ARTISTS_CONTEXT_CLUES), contextual_help: Some(&ARTISTS_CONTEXT_CLUES),
config: None, config: None,
}]), }]),
artist_info_tabs: TabState::new(vec![TabRoute {
title: "Albums".to_string(),
route: ActiveLidarrBlock::ArtistDetails.into(),
contextual_help: Some(&ARTIST_DETAILS_CONTEXT_CLUES),
config: None,
}]),
} }
} }
} }
@@ -169,7 +185,9 @@ impl LidarrData<'_> {
tags_map: tags_map(), tags_map: tags_map(),
..LidarrData::default() ..LidarrData::default()
}; };
lidarr_data.artists.set_items(vec![Artist::default()]); lidarr_data.albums.set_items(vec![album()]);
lidarr_data.albums.search = Some("album search".into());
lidarr_data.artists.set_items(vec![artist()]);
lidarr_data.artists.sorting(vec![SortOption { lidarr_data.artists.sorting(vec![SortOption {
name: "Name", name: "Name",
cmp_fn: Some(|a: &Artist, b: &Artist| a.artist_name.text.cmp(&b.artist_name.text)), cmp_fn: Some(|a: &Artist, b: &Artist| a.artist_name.text.cmp(&b.artist_name.text)),
@@ -193,6 +211,7 @@ impl LidarrData<'_> {
pub enum ActiveLidarrBlock { pub enum ActiveLidarrBlock {
#[default] #[default]
Artists, Artists,
ArtistDetails,
ArtistsSortPrompt, ArtistsSortPrompt,
AddArtistAlreadyInLibrary, AddArtistAlreadyInLibrary,
AddArtistConfirmPrompt, AddArtistConfirmPrompt,
@@ -206,6 +225,7 @@ pub enum ActiveLidarrBlock {
AddArtistSelectQualityProfile, AddArtistSelectQualityProfile,
AddArtistSelectRootFolder, AddArtistSelectRootFolder,
AddArtistTagsInput, AddArtistTagsInput,
AutomaticallySearchArtistPrompt,
DeleteArtistPrompt, DeleteArtistPrompt,
DeleteArtistConfirmPrompt, DeleteArtistConfirmPrompt,
DeleteArtistToggleDeleteFile, DeleteArtistToggleDeleteFile,
@@ -220,9 +240,12 @@ pub enum ActiveLidarrBlock {
EditArtistToggleMonitored, EditArtistToggleMonitored,
FilterArtists, FilterArtists,
FilterArtistsError, FilterArtistsError,
SearchAlbums,
SearchAlbumsError,
SearchArtists, SearchArtists,
SearchArtistsError, SearchArtistsError,
UpdateAllArtistsPrompt, UpdateAllArtistsPrompt,
UpdateAndScanArtistPrompt,
} }
pub static LIBRARY_BLOCKS: [ActiveLidarrBlock; 7] = [ pub static LIBRARY_BLOCKS: [ActiveLidarrBlock; 7] = [
@@ -235,6 +258,14 @@ pub static LIBRARY_BLOCKS: [ActiveLidarrBlock; 7] = [
ActiveLidarrBlock::UpdateAllArtistsPrompt, ActiveLidarrBlock::UpdateAllArtistsPrompt,
]; ];
pub static ARTIST_DETAILS_BLOCKS: [ActiveLidarrBlock; 5] = [
ActiveLidarrBlock::ArtistDetails,
ActiveLidarrBlock::AutomaticallySearchArtistPrompt,
ActiveLidarrBlock::SearchAlbums,
ActiveLidarrBlock::SearchAlbumsError,
ActiveLidarrBlock::UpdateAndScanArtistPrompt,
];
pub static ADD_ARTIST_BLOCKS: [ActiveLidarrBlock; 12] = [ pub static ADD_ARTIST_BLOCKS: [ActiveLidarrBlock; 12] = [
ActiveLidarrBlock::AddArtistAlreadyInLibrary, ActiveLidarrBlock::AddArtistAlreadyInLibrary,
ActiveLidarrBlock::AddArtistConfirmPrompt, ActiveLidarrBlock::AddArtistConfirmPrompt,
@@ -1,8 +1,11 @@
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use crate::app::lidarr::lidarr_context_clues::ARTISTS_CONTEXT_CLUES; use crate::app::lidarr::lidarr_context_clues::{
ARTIST_DETAILS_CONTEXT_CLUES, ARTISTS_CONTEXT_CLUES,
};
use crate::models::lidarr_models::Album;
use crate::models::servarr_data::lidarr::lidarr_data::{ use crate::models::servarr_data::lidarr::lidarr_data::{
ADD_ARTIST_BLOCKS, ADD_ARTIST_SELECTION_BLOCKS, DELETE_ARTIST_BLOCKS, ADD_ARTIST_BLOCKS, ADD_ARTIST_SELECTION_BLOCKS, ARTIST_DETAILS_BLOCKS, DELETE_ARTIST_BLOCKS,
DELETE_ARTIST_SELECTION_BLOCKS, EDIT_ARTIST_BLOCKS, EDIT_ARTIST_SELECTION_BLOCKS, DELETE_ARTIST_SELECTION_BLOCKS, EDIT_ARTIST_BLOCKS, EDIT_ARTIST_SELECTION_BLOCKS,
}; };
use crate::models::{ use crate::models::{
@@ -44,6 +47,18 @@ mod tests {
assert!(!lidarr_data.add_import_list_exclusion); assert!(!lidarr_data.add_import_list_exclusion);
} }
#[test]
fn test_reset_artist_info_tabs() {
let mut lidarr_data = LidarrData::default();
lidarr_data.albums.set_items(vec![Album::default()]);
lidarr_data.artist_info_tabs.index = 1;
lidarr_data.reset_artist_info_tabs();
assert_is_empty!(lidarr_data.albums);
assert_eq!(lidarr_data.artist_info_tabs.index, 0);
}
#[test] #[test]
fn test_tag_ids_to_display() { fn test_tag_ids_to_display() {
let mut tags_map = BiMap::new(); let mut tags_map = BiMap::new();
@@ -112,6 +127,7 @@ mod tests {
assert_none!(lidarr_data.add_artist_search); assert_none!(lidarr_data.add_artist_search);
assert!(!lidarr_data.add_import_list_exclusion); assert!(!lidarr_data.add_import_list_exclusion);
assert_none!(lidarr_data.add_searched_artists); assert_none!(lidarr_data.add_searched_artists);
assert_is_empty!(lidarr_data.albums);
assert_is_empty!(lidarr_data.artists); assert_is_empty!(lidarr_data.artists);
assert!(!lidarr_data.delete_artist_files); assert!(!lidarr_data.delete_artist_files);
assert_is_empty!(lidarr_data.disk_space_vec); assert_is_empty!(lidarr_data.disk_space_vec);
@@ -139,6 +155,18 @@ mod tests {
&ARTISTS_CONTEXT_CLUES &ARTISTS_CONTEXT_CLUES
); );
assert_none!(lidarr_data.main_tabs.tabs[0].config); assert_none!(lidarr_data.main_tabs.tabs[0].config);
assert_eq!(lidarr_data.artist_info_tabs.tabs.len(), 1);
assert_str_eq!(lidarr_data.artist_info_tabs.tabs[0].title, "Albums");
assert_eq!(
lidarr_data.artist_info_tabs.tabs[0].route,
ActiveLidarrBlock::ArtistDetails.into()
);
assert_some_eq_x!(
&lidarr_data.artist_info_tabs.tabs[0].contextual_help,
&ARTIST_DETAILS_CONTEXT_CLUES
);
assert_none!(lidarr_data.artist_info_tabs.tabs[0].config);
} }
#[test] #[test]
@@ -153,6 +181,16 @@ mod tests {
assert!(LIBRARY_BLOCKS.contains(&ActiveLidarrBlock::UpdateAllArtistsPrompt)); assert!(LIBRARY_BLOCKS.contains(&ActiveLidarrBlock::UpdateAllArtistsPrompt));
} }
#[test]
fn test_artist_details_blocks_contains_expected_blocks() {
assert_eq!(ARTIST_DETAILS_BLOCKS.len(), 5);
assert!(ARTIST_DETAILS_BLOCKS.contains(&ActiveLidarrBlock::ArtistDetails));
assert!(ARTIST_DETAILS_BLOCKS.contains(&ActiveLidarrBlock::AutomaticallySearchArtistPrompt));
assert!(ARTIST_DETAILS_BLOCKS.contains(&ActiveLidarrBlock::SearchAlbums));
assert!(ARTIST_DETAILS_BLOCKS.contains(&ActiveLidarrBlock::SearchAlbumsError));
assert!(ARTIST_DETAILS_BLOCKS.contains(&ActiveLidarrBlock::UpdateAndScanArtistPrompt));
}
#[test] #[test]
fn test_add_artist_blocks_contents() { fn test_add_artist_blocks_contents() {
assert_eq!(ADD_ARTIST_BLOCKS.len(), 12); assert_eq!(ADD_ARTIST_BLOCKS.len(), 12);
@@ -0,0 +1,106 @@
#[cfg(test)]
mod tests {
use mockito::Matcher;
use pretty_assertions::assert_eq;
use serde_json::{json, Value};
use crate::models::lidarr_models::{Album, LidarrSerdeable};
use crate::network::lidarr_network::lidarr_network_test_utils::test_utils::{ALBUM_JSON};
use crate::network::lidarr_network::LidarrEvent;
use crate::network::network_tests::test_utils::{test_network, MockServarrApi};
#[tokio::test]
async fn test_handle_get_albums_event() {
let albums_json = json!([{
"id": 1,
"title": "Test Album",
"foreignAlbumId": "test-foreign-album-id",
"monitored": true,
"anyReleaseOk": true,
"profileId": 1,
"duration": 180,
"albumType": "Album",
"genres": ["Classical"],
"ratings": {"votes": 15, "value": 8.4},
"releaseDate": "2023-01-01T00:00:00Z",
"statistics": {
"trackFileCount": 10,
"trackCount": 10,
"totalTrackCount": 10,
"sizeOnDisk": 1024,
"percentOfTracks": 99.9
}
}]);
let response: Vec<Album> = serde_json::from_value(albums_json.clone()).unwrap();
let (mock, app, _server) = MockServarrApi::get()
.returns(albums_json)
.query("artistId=1")
.build_for(LidarrEvent::GetAlbums(1))
.await;
app.lock().await.server_tabs.set_index(2);
let mut network = test_network(&app);
let result = network.handle_lidarr_event(LidarrEvent::GetAlbums(1)).await;
mock.assert_async().await;
let LidarrSerdeable::Albums(albums) = result.unwrap() else {
panic!("Expected Albums");
};
assert_eq!(albums, response);
assert!(!app.lock().await.data.lidarr_data.albums.is_empty());
}
#[tokio::test]
async fn test_handle_toggle_album_monitoring_event() {
let mut expected_body: Value = serde_json::from_str(ALBUM_JSON).unwrap();
*expected_body.get_mut("monitored").unwrap() = json!(false);
let (get_mock, app, mut server) = MockServarrApi::get()
.returns(serde_json::from_str(ALBUM_JSON).unwrap())
.path("/1")
.build_for(LidarrEvent::GetAlbums(1))
.await;
let put_mock = server
.mock("PUT", "/api/v1/album/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_ok!(
network
.handle_lidarr_event(LidarrEvent::ToggleAlbumMonitoring(1))
.await
);
get_mock.assert_async().await;
put_mock.assert_async().await;
}
#[tokio::test]
async fn test_handle_get_album_details_event() {
let expected_album: Album = serde_json::from_str(ALBUM_JSON).unwrap();
let (mock, app, _server) = MockServarrApi::get()
.returns(serde_json::from_str(ALBUM_JSON).unwrap())
.path("/1")
.build_for(LidarrEvent::GetAlbumDetails(1))
.await;
app.lock().await.server_tabs.set_index(2);
let mut network = test_network(&app);
let result = network
.handle_lidarr_event(LidarrEvent::GetAlbumDetails(1))
.await;
mock.assert_async().await;
let LidarrSerdeable::Album(album) = result.unwrap() else {
panic!("Expected Album");
};
assert_eq!(album, expected_album);
}
}
@@ -0,0 +1,121 @@
use crate::models::lidarr_models::{Album};
use crate::network::lidarr_network::LidarrEvent;
use crate::network::{Network, RequestMethod};
use anyhow::Result;
use log::{debug, info, warn};
use serde_json::{Value, json};
#[cfg(test)]
#[path = "lidarr_albums_network_tests.rs"]
mod lidarr_albums_network_tests;
impl Network<'_, '_> {
pub(in crate::network::lidarr_network) async fn get_albums(
&mut self,
artist_id: i64,
) -> Result<Vec<Album>> {
info!("Fetching albums for Lidarr artist with ID: {artist_id}");
let event = LidarrEvent::GetAlbums(artist_id);
let request_props = self
.request_props_from(
event,
RequestMethod::Get,
None::<()>,
None,
Some(format!("artistId={artist_id}")),
)
.await;
self
.handle_request::<(), Vec<Album>>(request_props, |mut albums_vec, mut app| {
albums_vec.sort_by(|a, b| a.id.cmp(&b.id));
app.data.lidarr_data.albums.set_items(albums_vec);
})
.await
}
pub(in crate::network::lidarr_network) async fn get_album_details(
&mut self,
album_id: i64,
) -> Result<Album> {
info!("Fetching details for Lidarr album with ID: {album_id}");
let event = LidarrEvent::GetAlbumDetails(album_id);
let request_props = self
.request_props_from(
event,
RequestMethod::Get,
None::<()>,
Some(format!("/{album_id}")),
None,
)
.await;
self
.handle_request::<(), Album>(request_props, |_, _| ())
.await
}
pub(in crate::network::lidarr_network) async fn toggle_album_monitoring(
&mut self,
album_id: i64,
) -> Result<()> {
let event = LidarrEvent::ToggleAlbumMonitoring(album_id);
info!("Toggling album monitoring for album with ID: {album_id}");
info!("Fetching album details for album with ID: {album_id}");
let detail_event = LidarrEvent::GetAlbums(0);
let request_props = self
.request_props_from(
detail_event,
RequestMethod::Get,
None::<()>,
Some(format!("/{album_id}")),
None,
)
.await;
let mut response = String::new();
self
.handle_request::<(), Value>(request_props, |detailed_album_body, _| {
response = detailed_album_body.to_string()
})
.await?;
info!("Constructing toggle album monitoring body");
match serde_json::from_str::<Value>(&response) {
Ok(mut detailed_album_body) => {
let monitored = detailed_album_body
.get("monitored")
.unwrap()
.as_bool()
.unwrap();
*detailed_album_body.get_mut("monitored").unwrap() = json!(!monitored);
debug!("Toggle album monitoring body: {detailed_album_body:?}");
let request_props = self
.request_props_from(
event,
RequestMethod::Put,
Some(detailed_album_body),
Some(format!("/{album_id}")),
None,
)
.await;
self
.handle_request::<Value, ()>(request_props, |_, _| ())
.await
}
Err(_) => {
warn!("Request for detailed album body was interrupted");
Ok(())
}
}
}
}
@@ -134,11 +134,10 @@ mod tests {
app.lock().await.server_tabs.set_index(2); app.lock().await.server_tabs.set_index(2);
let mut network = test_network(&app); let mut network = test_network(&app);
assert!( assert_ok!(
network network
.handle_lidarr_event(LidarrEvent::ToggleArtistMonitoring(1)) .handle_lidarr_event(LidarrEvent::ToggleArtistMonitoring(1))
.await .await
.is_ok()
); );
get_mock.assert_async().await; get_mock.assert_async().await;
@@ -167,6 +166,52 @@ mod tests {
mock.assert_async().await; mock.assert_async().await;
} }
#[tokio::test]
async fn test_handle_update_and_scan_artist_event() {
let (mock, app, _server) = MockServarrApi::post()
.with_request_body(json!({
"name": "RefreshArtist",
"artistId": 1
}))
.returns(json!({}))
.build_for(LidarrEvent::UpdateAndScanArtist(1))
.await;
app.lock().await.server_tabs.set_index(2);
let mut network = test_network(&app);
assert!(
network
.handle_lidarr_event(LidarrEvent::UpdateAndScanArtist(1))
.await
.is_ok()
);
mock.assert_async().await;
}
#[tokio::test]
async fn test_handle_trigger_automatic_artist_search_event() {
let (mock, app, _server) = MockServarrApi::post()
.with_request_body(json!({
"name": "ArtistSearch",
"artistId": 1
}))
.returns(json!({}))
.build_for(LidarrEvent::TriggerAutomaticArtistSearch(1))
.await;
app.lock().await.server_tabs.set_index(2);
let mut network = test_network(&app);
assert!(
network
.handle_lidarr_event(LidarrEvent::TriggerAutomaticArtistSearch(1))
.await
.is_ok()
);
mock.assert_async().await;
}
#[tokio::test] #[tokio::test]
async fn test_handle_edit_artist_event() { async fn test_handle_edit_artist_event() {
let mut expected_body: Value = serde_json::from_str(ARTIST_JSON).unwrap(); let mut expected_body: Value = serde_json::from_str(ARTIST_JSON).unwrap();
@@ -0,0 +1,399 @@
use anyhow::Result;
use log::{debug, info, warn};
use serde_json::{Value, json};
use crate::models::Route;
use crate::models::lidarr_models::{
AddArtistBody, AddArtistSearchResult, Artist, DeleteArtistParams, EditArtistParams,
LidarrCommandBody,
};
use crate::models::servarr_data::lidarr::lidarr_data::ActiveLidarrBlock;
use crate::models::stateful_table::StatefulTable;
use crate::network::lidarr_network::LidarrEvent;
use crate::network::{Network, RequestMethod};
use urlencoding::encode;
#[cfg(test)]
#[path = "lidarr_artists_network_tests.rs"]
mod lidarr_artists_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<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, |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
}
pub(in crate::network::lidarr_network) async fn get_artist_details(
&mut self,
artist_id: i64,
) -> Result<Artist> {
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::<Value>(&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::<Value, ()>(request_props, |_, _| ())
.await
}
Err(_) => {
warn!("Request for detailed artist body was interrupted");
Ok(())
}
}
}
pub(in crate::network::lidarr_network) async fn update_all_artists(&mut self) -> Result<Value> {
info!("Updating all artists");
let event = LidarrEvent::UpdateAllArtists;
let body = LidarrCommandBody {
name: "RefreshArtist".to_owned(),
..LidarrCommandBody::default()
};
let request_props = self
.request_props_from(event, RequestMethod::Post, Some(body), None, None)
.await;
self
.handle_request::<LidarrCommandBody, Value>(request_props, |_, _| ())
.await
}
pub(in crate::network::lidarr_network) async fn update_and_scan_artist(
&mut self,
artist_id: i64,
) -> Result<Value> {
let event = LidarrEvent::UpdateAndScanArtist(artist_id);
info!("Updating and scanning artist with ID: {artist_id}");
let body = LidarrCommandBody {
name: "RefreshArtist".to_owned(),
artist_id: Some(artist_id),
..LidarrCommandBody::default()
};
let request_props = self
.request_props_from(event, RequestMethod::Post, Some(body), None, None)
.await;
self
.handle_request::<LidarrCommandBody, Value>(request_props, |_, _| ())
.await
}
pub(in crate::network::lidarr_network) async fn trigger_automatic_artist_search(
&mut self,
artist_id: i64,
) -> Result<Value> {
let event = LidarrEvent::TriggerAutomaticArtistSearch(artist_id);
info!("Searching indexers for artist with ID: {artist_id}");
let body = LidarrCommandBody {
name: "ArtistSearch".to_owned(),
artist_id: Some(artist_id),
..LidarrCommandBody::default()
};
let request_props = self
.request_props_from(event, RequestMethod::Post, Some(body), None, None)
.await;
self
.handle_request::<LidarrCommandBody, Value>(request_props, |_, _| ())
.await
}
pub(in crate::network::lidarr_network) async fn search_artist(
&mut self,
query: String,
) -> Result<Vec<AddArtistSearchResult>> {
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<AddArtistSearchResult>>(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 add_artist(
&mut self,
mut add_artist_body: AddArtistBody,
) -> Result<Value> {
info!("Adding Lidarr artist: {}", add_artist_body.artist_name);
if let Some(tag_input_str) = add_artist_body.tag_input_string.as_ref() {
let tag_ids_vec = self.extract_and_add_lidarr_tag_ids_vec(tag_input_str).await;
add_artist_body.tags = tag_ids_vec;
}
let event = LidarrEvent::AddArtist(AddArtistBody::default());
let request_props = self
.request_props_from(
event,
RequestMethod::Post,
Some(add_artist_body),
None,
None,
)
.await;
self
.handle_request::<AddArtistBody, Value>(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::<Value, ()>(request_props, |_, _| ())
.await
}
}
+2 -356
View File
@@ -1,356 +1,2 @@
use anyhow::Result; mod albums;
use log::{debug, info, warn}; mod artists;
use serde_json::{Value, json};
use crate::models::Route;
use crate::models::lidarr_models::{
AddArtistBody, 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"]
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<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, |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
}
pub(in crate::network::lidarr_network) async fn get_artist_details(
&mut self,
artist_id: i64,
) -> Result<Artist> {
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::<Value>(&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::<Value, ()>(request_props, |_, _| ())
.await
}
Err(_) => {
warn!("Request for detailed artist body was interrupted");
Ok(())
}
}
}
pub(in crate::network::lidarr_network) async fn update_all_artists(&mut self) -> Result<Value> {
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::<CommandBody, Value>(request_props, |_, _| ())
.await
}
pub(in crate::network::lidarr_network) async fn search_artist(
&mut self,
query: String,
) -> Result<Vec<AddArtistSearchResult>> {
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<AddArtistSearchResult>>(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 add_artist(
&mut self,
mut add_artist_body: AddArtistBody,
) -> Result<Value> {
info!("Adding Lidarr artist: {}", add_artist_body.artist_name);
if let Some(tag_input_str) = add_artist_body.tag_input_string.as_ref() {
let tag_ids_vec = self.extract_and_add_lidarr_tag_ids_vec(tag_input_str).await;
add_artist_body.tags = tag_ids_vec;
}
let event = LidarrEvent::AddArtist(AddArtistBody::default());
let request_props = self
.request_props_from(
event,
RequestMethod::Post,
Some(add_artist_body),
None,
None,
)
.await;
self
.handle_request::<AddArtistBody, Value>(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::<Value, ()>(request_props, |_, _| ())
.await
}
}
@@ -3,9 +3,9 @@
pub mod test_utils { pub mod test_utils {
use crate::models::HorizontallyScrollableText; use crate::models::HorizontallyScrollableText;
use crate::models::lidarr_models::{ use crate::models::lidarr_models::{
AddArtistSearchResult, Artist, ArtistStatistics, ArtistStatus, DownloadRecord, DownloadStatus, AddArtistSearchResult, Album, AlbumStatistics, Artist, ArtistStatistics, ArtistStatus,
DownloadsResponse, EditArtistParams, Member, MetadataProfile, NewItemMonitorType, Ratings, DownloadRecord, DownloadStatus, DownloadsResponse, EditArtistParams, Member, MetadataProfile,
SystemStatus, NewItemMonitorType, Ratings, SystemStatus,
}; };
use crate::models::servarr_models::{QualityProfile, RootFolder, Tag}; use crate::models::servarr_models::{QualityProfile, RootFolder, Tag};
use bimap::BiMap; use bimap::BiMap;
@@ -50,6 +50,27 @@ pub mod test_utils {
"percentOfTracks": 99.9 "percentOfTracks": 99.9
} }
}"#; }"#;
pub const ALBUM_JSON: &str = r#"{
"id": 1,
"title": "Test Album",
"foreignAlbumId": "test-foreign-album-id",
"monitored": true,
"anyReleaseOk": true,
"profileId": 1,
"duration": 180,
"albumType": "Album",
"genres": ["Classical"],
"ratings": {"votes": 15, "value": 8.4},
"releaseDate": "2023-01-01T00:00:00Z",
"statistics": {
"trackFileCount": 10,
"trackCount": 10,
"totalTrackCount": 10,
"sizeOnDisk": 1024,
"percentOfTracks": 99.9
}
}"#;
pub fn member() -> Member { pub fn member() -> Member {
Member { Member {
@@ -199,4 +220,33 @@ pub mod test_utils {
ratings: Some(ratings()), ratings: Some(ratings()),
} }
} }
pub fn album_statistics() -> AlbumStatistics {
AlbumStatistics {
track_file_count: 10,
track_count: 10,
total_track_count: 10,
size_on_disk: 1024,
percent_of_tracks: 99.9,
}
}
pub fn album() -> Album {
Album {
id: 1,
title: "Test Album".into(),
foreign_album_id: "test-foreign-album-id".to_string(),
monitored: true,
any_release_ok: true,
profile_id: 1,
duration: 180,
album_type: Some("Album".to_owned()),
genres: vec!["Classical".to_owned()],
ratings: Some(ratings()),
release_date: Some(DateTime::from(
DateTime::parse_from_rfc3339("2023-01-01T00:00:00Z").unwrap(),
)),
statistics: Some(album_statistics()),
}
}
} }
@@ -46,10 +46,24 @@ mod tests {
} }
#[rstest] #[rstest]
fn test_resource_command(#[values(LidarrEvent::UpdateAllArtists)] event: LidarrEvent) { fn test_resource_command(
#[values(
LidarrEvent::UpdateAllArtists,
LidarrEvent::TriggerAutomaticArtistSearch(0),
LidarrEvent::UpdateAndScanArtist(0)
)]
event: LidarrEvent,
) {
assert_str_eq!(event.resource(), "/command"); assert_str_eq!(event.resource(), "/command");
} }
#[rstest]
fn test_resource_albums(
#[values(LidarrEvent::GetAlbums(0), LidarrEvent::ToggleAlbumMonitoring(0), LidarrEvent::GetAlbumDetails(0))] event: LidarrEvent,
) {
assert_str_eq!(event.resource(), "/album");
}
#[rstest] #[rstest]
#[case(LidarrEvent::GetDiskSpace, "/diskspace")] #[case(LidarrEvent::GetDiskSpace, "/diskspace")]
#[case(LidarrEvent::GetDownloads(500), "/queue")] #[case(LidarrEvent::GetDownloads(500), "/queue")]
+25 -1
View File
@@ -28,6 +28,8 @@ pub enum LidarrEvent {
DeleteArtist(DeleteArtistParams), DeleteArtist(DeleteArtistParams),
DeleteTag(i64), DeleteTag(i64),
EditArtist(EditArtistParams), EditArtist(EditArtistParams),
GetAlbums(i64),
GetAlbumDetails(i64),
GetArtistDetails(i64), GetArtistDetails(i64),
GetDiskSpace, GetDiskSpace,
GetDownloads(u64), GetDownloads(u64),
@@ -41,8 +43,11 @@ pub enum LidarrEvent {
HealthCheck, HealthCheck,
ListArtists, ListArtists,
SearchNewArtist(String), SearchNewArtist(String),
ToggleAlbumMonitoring(i64),
ToggleArtistMonitoring(i64), ToggleArtistMonitoring(i64),
TriggerAutomaticArtistSearch(i64),
UpdateAllArtists, UpdateAllArtists,
UpdateAndScanArtist(i64),
} }
impl NetworkResource for LidarrEvent { impl NetworkResource for LidarrEvent {
@@ -55,10 +60,13 @@ impl NetworkResource for LidarrEvent {
| LidarrEvent::ListArtists | LidarrEvent::ListArtists
| LidarrEvent::AddArtist(_) | LidarrEvent::AddArtist(_)
| LidarrEvent::ToggleArtistMonitoring(_) => "/artist", | LidarrEvent::ToggleArtistMonitoring(_) => "/artist",
LidarrEvent::GetAlbums(_) | LidarrEvent::ToggleAlbumMonitoring(_) | LidarrEvent::GetAlbumDetails(_) => "/album",
LidarrEvent::GetDiskSpace => "/diskspace", LidarrEvent::GetDiskSpace => "/diskspace",
LidarrEvent::GetDownloads(_) => "/queue", LidarrEvent::GetDownloads(_) => "/queue",
LidarrEvent::GetHostConfig | LidarrEvent::GetSecurityConfig => "/config/host", LidarrEvent::GetHostConfig | LidarrEvent::GetSecurityConfig => "/config/host",
LidarrEvent::UpdateAllArtists => "/command", LidarrEvent::TriggerAutomaticArtistSearch(_)
| LidarrEvent::UpdateAllArtists
| LidarrEvent::UpdateAndScanArtist(_) => "/command",
LidarrEvent::GetMetadataProfiles => "/metadataprofile", LidarrEvent::GetMetadataProfiles => "/metadataprofile",
LidarrEvent::GetQualityProfiles => "/qualityprofile", LidarrEvent::GetQualityProfiles => "/qualityprofile",
LidarrEvent::GetRootFolders => "/rootfolder", LidarrEvent::GetRootFolders => "/rootfolder",
@@ -89,10 +97,14 @@ impl Network<'_, '_> {
.delete_lidarr_tag(tag_id) .delete_lidarr_tag(tag_id)
.await .await
.map(LidarrSerdeable::from), .map(LidarrSerdeable::from),
LidarrEvent::GetAlbums(artist_id) => {
self.get_albums(artist_id).await.map(LidarrSerdeable::from)
}
LidarrEvent::GetArtistDetails(artist_id) => self LidarrEvent::GetArtistDetails(artist_id) => self
.get_artist_details(artist_id) .get_artist_details(artist_id)
.await .await
.map(LidarrSerdeable::from), .map(LidarrSerdeable::from),
LidarrEvent::GetAlbumDetails(album_id) => self.get_album_details(album_id).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 LidarrEvent::GetDownloads(count) => self
.get_lidarr_downloads(count) .get_lidarr_downloads(count)
@@ -128,11 +140,23 @@ impl Network<'_, '_> {
LidarrEvent::SearchNewArtist(query) => { LidarrEvent::SearchNewArtist(query) => {
self.search_artist(query).await.map(LidarrSerdeable::from) self.search_artist(query).await.map(LidarrSerdeable::from)
} }
LidarrEvent::ToggleAlbumMonitoring(album_id) => self
.toggle_album_monitoring(album_id)
.await
.map(LidarrSerdeable::from),
LidarrEvent::ToggleArtistMonitoring(artist_id) => self LidarrEvent::ToggleArtistMonitoring(artist_id) => self
.toggle_artist_monitoring(artist_id) .toggle_artist_monitoring(artist_id)
.await .await
.map(LidarrSerdeable::from), .map(LidarrSerdeable::from),
LidarrEvent::TriggerAutomaticArtistSearch(artist_id) => self
.trigger_automatic_artist_search(artist_id)
.await
.map(LidarrSerdeable::from),
LidarrEvent::UpdateAllArtists => self.update_all_artists().await.map(LidarrSerdeable::from), LidarrEvent::UpdateAllArtists => self.update_all_artists().await.map(LidarrSerdeable::from),
LidarrEvent::UpdateAndScanArtist(artist_id) => self
.update_and_scan_artist(artist_id)
.await
.map(LidarrSerdeable::from),
LidarrEvent::EditArtist(params) => self.edit_artist(params).await.map(LidarrSerdeable::from), LidarrEvent::EditArtist(params) => self.edit_artist(params).await.map(LidarrSerdeable::from),
LidarrEvent::AddArtist(body) => self.add_artist(body).await.map(LidarrSerdeable::from), LidarrEvent::AddArtist(body) => self.add_artist(body).await.map(LidarrSerdeable::from),
} }
@@ -0,0 +1,327 @@
use chrono::Utc;
use deunicode::deunicode;
use ratatui::Frame;
use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::style::Stylize;
use ratatui::text::Line;
use ratatui::widgets::{Cell, Paragraph, Row, Wrap};
use regex::Regex;
use crate::app::App;
use crate::models::Route;
use crate::models::lidarr_models::Album;
use crate::models::servarr_data::lidarr::lidarr_data::{ARTIST_DETAILS_BLOCKS, ActiveLidarrBlock};
use crate::ui::styles::ManagarrStyle;
use crate::ui::utils::{
borderless_block, get_width_from_percentage, layout_block_top_border, title_block,
};
use crate::ui::widgets::confirmation_prompt::ConfirmationPrompt;
use crate::ui::widgets::managarr_table::ManagarrTable;
use crate::ui::widgets::popup::{Popup, Size};
use crate::ui::{DrawUi, draw_popup, draw_tabs};
use crate::utils::convert_to_gb;
#[cfg(test)]
#[path = "artist_details_ui_tests.rs"]
mod artist_details_ui_tests;
pub(super) struct ArtistDetailsUi;
impl DrawUi for ArtistDetailsUi {
fn accepts(route: Route) -> bool {
let Route::Lidarr(active_lidarr_block, _) = route else {
return false;
};
ARTIST_DETAILS_BLOCKS.contains(&active_lidarr_block)
}
fn draw(f: &mut Frame<'_>, app: &mut App<'_>, _area: Rect) {
let route = app.get_current_route();
if let Route::Lidarr(active_lidarr_block, _) = route {
let draw_artist_details_popup = |f: &mut Frame<'_>, app: &mut App<'_>, popup_area: Rect| {
f.render_widget(
title_block(
&app
.data
.lidarr_data
.artists
.current_selection()
.artist_name
.text,
),
popup_area,
);
let [description_area, detail_area] =
Layout::vertical([Constraint::Percentage(37), Constraint::Fill(0)])
.margin(1)
.areas(popup_area);
draw_artist_description(f, app, description_area);
let content_area = draw_tabs(
f,
detail_area,
"Artist Details",
&app.data.lidarr_data.artist_info_tabs,
);
draw_artist_details(f, app, content_area);
match active_lidarr_block {
ActiveLidarrBlock::AutomaticallySearchArtistPrompt => {
let prompt = format!(
"Do you want to trigger an automatic search of your indexers for all monitored album(s) for the artist: {}?",
app.data.lidarr_data.artists.current_selection().artist_name
);
let confirmation_prompt = ConfirmationPrompt::new()
.title("Automatic Artist Search")
.prompt(&prompt)
.yes_no_value(app.data.lidarr_data.prompt_confirm);
f.render_widget(
Popup::new(confirmation_prompt).size(Size::MediumPrompt),
f.area(),
);
}
ActiveLidarrBlock::UpdateAndScanArtistPrompt => {
let prompt = format!(
"Do you want to trigger an update and disk scan for the artist: {}?",
app.data.lidarr_data.artists.current_selection().artist_name
);
let confirmation_prompt = ConfirmationPrompt::new()
.title("Update and Scan")
.prompt(&prompt)
.yes_no_value(app.data.lidarr_data.prompt_confirm);
f.render_widget(
Popup::new(confirmation_prompt).size(Size::MediumPrompt),
f.area(),
);
}
_ => (),
}
};
draw_popup(f, app, draw_artist_details_popup, Size::XXLarge);
}
}
}
fn draw_artist_description(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) {
let current_selection = app.data.lidarr_data.artists.current_selection();
let monitored = if current_selection.monitored {
"Yes"
} else {
"No"
};
let quality_profile = app
.data
.lidarr_data
.quality_profile_map
.get_by_left(&current_selection.quality_profile_id)
.cloned()
.unwrap_or_default();
let metadata_profile = app
.data
.lidarr_data
.metadata_profile_map
.get_by_left(&current_selection.metadata_profile_id)
.cloned()
.unwrap_or_default();
let overview = Regex::new(r"[\r\n\t]")
.unwrap()
.replace_all(
&deunicode(
current_selection
.overview
.as_ref()
.unwrap_or(&String::new()),
),
"",
)
.to_string();
let mut artist_description = vec![
Line::from(vec![
"Artist: ".primary().bold(),
current_selection.artist_name.text.clone().primary().bold(),
]),
Line::from(vec![
"Overview: ".primary().bold(),
overview.default_color(),
]),
Line::from(vec![
"Disambiguation: ".primary().bold(),
current_selection
.disambiguation
.clone()
.unwrap_or_default()
.default_color(),
]),
Line::from(vec![
"Type: ".primary().bold(),
current_selection
.artist_type
.clone()
.unwrap_or_default()
.default_color(),
]),
Line::from(vec![
"Status: ".primary().bold(),
current_selection.status.to_display_str().default_color(),
]),
Line::from(vec![
"Genres: ".primary().bold(),
current_selection.genres.join(", ").default_color(),
]),
Line::from(vec![
"Rating: ".primary().bold(),
current_selection
.ratings
.as_ref()
.map_or_else(
|| "N/A".to_owned(),
|r| format!("{}%", (r.value * 10.0) as i32),
)
.default_color(),
]),
Line::from(vec![
"Path: ".primary().bold(),
current_selection.path.clone().default_color(),
]),
Line::from(vec![
"Quality Profile: ".primary().bold(),
quality_profile.default_color(),
]),
Line::from(vec![
"Metadata Profile: ".primary().bold(),
metadata_profile.default_color(),
]),
Line::from(vec![
"Monitored: ".primary().bold(),
monitored.default_color(),
]),
];
if let Some(stats) = current_selection.statistics.as_ref() {
let size = convert_to_gb(stats.size_on_disk);
artist_description.extend(vec![
Line::from(vec![
"Albums: ".primary().bold(),
stats.album_count.to_string().default_color(),
]),
Line::from(vec![
"Tracks: ".primary().bold(),
format!("{}/{}", stats.track_file_count, stats.total_track_count).default_color(),
]),
Line::from(vec![
"Size on Disk: ".primary().bold(),
format!("{size:.2} GB").default_color(),
]),
]);
}
let description_paragraph = Paragraph::new(artist_description)
.block(borderless_block())
.wrap(Wrap { trim: true });
f.render_widget(description_paragraph, area);
}
fn draw_artist_details(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) {
if let Route::Lidarr(active_lidarr_block, _) =
app.data.lidarr_data.artist_info_tabs.get_active_route()
{
match active_lidarr_block {
ActiveLidarrBlock::ArtistDetails => draw_albums_table(f, app, area),
_ => (),
}
}
}
fn draw_albums_table(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.albums.is_empty() {
Album::default()
} else {
app.data.lidarr_data.albums.current_selection().clone()
};
let content = Some(&mut app.data.lidarr_data.albums);
let album_row_mapping = |album: &Album| {
album.title.scroll_left_or_reset(
get_width_from_percentage(area, 33),
*album == current_selection,
app.ui_scroll_tick_count == 0,
);
let monitored = if album.monitored { "🏷" } else { "" };
let album_type = album.album_type.clone().unwrap_or_default();
let release_date = album
.release_date
.map_or_else(|| "N/A".to_owned(), |d| d.format("%Y-%m-%d").to_string());
let track_count = album.statistics.as_ref().map_or_else(
|| "0/0".to_owned(),
|s| format!("{}/{}", s.track_file_count, s.total_track_count),
);
let size = album
.statistics
.as_ref()
.map_or(0f64, |s| convert_to_gb(s.size_on_disk));
let duration_mins = album.duration / 60000;
let row = Row::new(vec![
Cell::from(monitored.to_owned()),
Cell::from(album.title.to_string()),
Cell::from(album_type),
Cell::from(track_count),
Cell::from(format!("{duration_mins} min")),
Cell::from(release_date),
Cell::from(format!("{size:.2} GB")),
]);
if !album.monitored {
row.unmonitored()
} else if let Some(stats) = album.statistics.as_ref() {
if stats.track_file_count == stats.total_track_count && stats.total_track_count > 0 {
row.downloaded()
} else if let Some(release_date) = album.release_date.as_ref() {
if release_date > &Utc::now() {
row.unreleased()
} else {
row.missing()
}
} else {
row.missing()
}
} else {
row.indeterminate()
}
};
let is_searching = active_lidarr_block == ActiveLidarrBlock::SearchAlbums;
let album_table = ManagarrTable::new(content, album_row_mapping)
.block(layout_block_top_border())
.loading(app.is_loading)
.searching(is_searching)
.search_produced_empty_results(active_lidarr_block == ActiveLidarrBlock::SearchAlbumsError)
.headers([
"Monitored",
"Title",
"Type",
"Tracks",
"Duration",
"Release Date",
"Size",
])
.constraints([
Constraint::Percentage(7),
Constraint::Percentage(35),
Constraint::Percentage(10),
Constraint::Percentage(10),
Constraint::Percentage(10),
Constraint::Percentage(13),
Constraint::Percentage(15),
]);
if is_searching {
album_table.show_cursor(f, area);
}
f.render_widget(album_table, area);
}
}
@@ -0,0 +1,125 @@
#[cfg(test)]
mod tests {
use strum::IntoEnumIterator;
use crate::models::servarr_data::lidarr::lidarr_data::{
ARTIST_DETAILS_BLOCKS, ActiveLidarrBlock,
};
use crate::ui::DrawUi;
use crate::ui::lidarr_ui::library::artist_details_ui::ArtistDetailsUi;
#[test]
fn test_artist_details_ui_accepts() {
let mut blocks = ARTIST_DETAILS_BLOCKS.clone().to_vec();
ActiveLidarrBlock::iter().for_each(|active_lidarr_block| {
if blocks.contains(&active_lidarr_block) {
assert!(ArtistDetailsUi::accepts(active_lidarr_block.into()));
} else {
assert!(!ArtistDetailsUi::accepts(active_lidarr_block.into()));
}
});
}
mod snapshot_tests {
use rstest::rstest;
use crate::app::App;
use crate::models::servarr_data::lidarr::lidarr_data::ActiveLidarrBlock;
use crate::models::stateful_table::StatefulTable;
use crate::ui::DrawUi;
use crate::ui::lidarr_ui::library::artist_details_ui::ArtistDetailsUi;
use crate::ui::ui_test_utils::test_utils::{TerminalSize, render_to_string_with_app};
#[rstest]
#[case(ActiveLidarrBlock::ArtistDetails, 0)]
#[case(ActiveLidarrBlock::SearchAlbums, 0)]
#[case(ActiveLidarrBlock::SearchAlbumsError, 0)]
#[case(ActiveLidarrBlock::UpdateAndScanArtistPrompt, 0)]
#[case(ActiveLidarrBlock::AutomaticallySearchArtistPrompt, 0)]
fn test_artist_details_ui_renders(
#[case] active_lidarr_block: ActiveLidarrBlock,
#[case] index: usize,
) {
let mut app = App::test_default_fully_populated();
app.push_navigation_stack(active_lidarr_block.into());
app.data.lidarr_data.artist_info_tabs.set_index(index);
let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| {
ArtistDetailsUi::draw(f, app, f.area());
});
insta::assert_snapshot!(
format!("artist_details_{active_lidarr_block}_{index}"),
output
);
}
#[rstest]
#[case(ActiveLidarrBlock::ArtistDetails, 0)]
fn test_artist_details_ui_renders_artist_details_loading(
#[case] active_lidarr_block: ActiveLidarrBlock,
#[case] index: usize,
) {
let mut app = App::test_default_fully_populated();
app.push_navigation_stack(active_lidarr_block.into());
app.data.lidarr_data.artist_info_tabs.set_index(index);
app.is_loading = true;
let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| {
ArtistDetailsUi::draw(f, app, f.area());
});
insta::assert_snapshot!(
format!("loading_artist_details_{active_lidarr_block}"),
output
);
}
#[rstest]
#[case(ActiveLidarrBlock::ArtistDetails, 0)]
fn test_artist_details_ui_renders_artist_details_empty(
#[case] active_lidarr_block: ActiveLidarrBlock,
#[case] index: usize,
) {
let mut app = App::test_default_fully_populated();
app.data.lidarr_data.albums = StatefulTable::default();
app.push_navigation_stack(active_lidarr_block.into());
app.data.lidarr_data.artist_info_tabs.set_index(index);
let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| {
ArtistDetailsUi::draw(f, app, f.area());
});
insta::assert_snapshot!(
format!("empty_artist_details_{active_lidarr_block}"),
output
);
}
#[test]
fn test_artist_details_ui_renders_update_and_scan_prompt_over_artist_details() {
let mut app = App::test_default_fully_populated();
app.push_navigation_stack(ActiveLidarrBlock::ArtistDetails.into());
app.push_navigation_stack(ActiveLidarrBlock::UpdateAndScanArtistPrompt.into());
let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| {
ArtistDetailsUi::draw(f, app, f.area());
});
insta::assert_snapshot!(output);
}
#[test]
fn test_artist_details_ui_renders_automatic_search_prompt_over_artist_details() {
let mut app = App::test_default_fully_populated();
app.push_navigation_stack(ActiveLidarrBlock::ArtistDetails.into());
app.push_navigation_stack(ActiveLidarrBlock::AutomaticallySearchArtistPrompt.into());
let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| {
ArtistDetailsUi::draw(f, app, f.area());
});
insta::assert_snapshot!(output);
}
}
}
+11 -2
View File
@@ -7,10 +7,13 @@ use ratatui::widgets::ListItem;
use crate::app::App; use crate::app::App;
use crate::models::Route; use crate::models::Route;
use crate::models::servarr_data::lidarr::lidarr_data::{ActiveLidarrBlock, EDIT_ARTIST_BLOCKS}; use crate::models::servarr_data::lidarr::lidarr_data::{
ARTIST_DETAILS_BLOCKS, ActiveLidarrBlock, EDIT_ARTIST_BLOCKS,
};
use crate::models::servarr_data::lidarr::modals::EditArtistModal; use crate::models::servarr_data::lidarr::modals::EditArtistModal;
use crate::render_selectable_input_box; use crate::render_selectable_input_box;
use crate::ui::lidarr_ui::library::artist_details_ui::ArtistDetailsUi;
use crate::ui::utils::title_block_centered; use crate::ui::utils::title_block_centered;
use crate::ui::widgets::button::Button; use crate::ui::widgets::button::Button;
use crate::ui::widgets::checkbox::Checkbox; use crate::ui::widgets::checkbox::Checkbox;
@@ -34,7 +37,13 @@ impl DrawUi for EditArtistUi {
} }
fn draw(f: &mut Frame<'_>, app: &mut App<'_>, _area: Rect) { fn draw(f: &mut Frame<'_>, app: &mut App<'_>, _area: Rect) {
if let Route::Lidarr(active_lidarr_block, _context_option) = app.get_current_route() { if let Route::Lidarr(active_lidarr_block, context_option) = app.get_current_route() {
if let Some(context) = context_option
&& ARTIST_DETAILS_BLOCKS.contains(&context)
{
draw_popup(f, app, ArtistDetailsUi::draw, Size::Large);
}
let draw_edit_artist_prompt = |f: &mut Frame<'_>, app: &mut App<'_>, prompt_area: Rect| { let draw_edit_artist_prompt = |f: &mut Frame<'_>, app: &mut App<'_>, prompt_area: Rect| {
draw_edit_artist_confirmation_prompt(f, app, prompt_area); draw_edit_artist_confirmation_prompt(f, app, prompt_area);
+48 -2
View File
@@ -4,7 +4,8 @@ mod tests {
use crate::models::lidarr_models::{Artist, ArtistStatistics, ArtistStatus}; use crate::models::lidarr_models::{Artist, ArtistStatistics, ArtistStatus};
use crate::models::servarr_data::lidarr::lidarr_data::{ use crate::models::servarr_data::lidarr::lidarr_data::{
ADD_ARTIST_BLOCKS, ActiveLidarrBlock, DELETE_ARTIST_BLOCKS, EDIT_ARTIST_BLOCKS, LIBRARY_BLOCKS, ADD_ARTIST_BLOCKS, ARTIST_DETAILS_BLOCKS, ActiveLidarrBlock, DELETE_ARTIST_BLOCKS,
EDIT_ARTIST_BLOCKS, LIBRARY_BLOCKS,
}; };
use crate::ui::DrawUi; use crate::ui::DrawUi;
use crate::ui::lidarr_ui::library::{LibraryUi, decorate_artist_row_with_style}; use crate::ui::lidarr_ui::library::{LibraryUi, decorate_artist_row_with_style};
@@ -19,6 +20,7 @@ mod tests {
library_ui_blocks.extend(DELETE_ARTIST_BLOCKS); library_ui_blocks.extend(DELETE_ARTIST_BLOCKS);
library_ui_blocks.extend(EDIT_ARTIST_BLOCKS); library_ui_blocks.extend(EDIT_ARTIST_BLOCKS);
library_ui_blocks.extend(ADD_ARTIST_BLOCKS); library_ui_blocks.extend(ADD_ARTIST_BLOCKS);
library_ui_blocks.extend(ARTIST_DETAILS_BLOCKS);
for active_lidarr_block in ActiveLidarrBlock::iter() { for active_lidarr_block in ActiveLidarrBlock::iter() {
if library_ui_blocks.contains(&active_lidarr_block) { if library_ui_blocks.contains(&active_lidarr_block) {
@@ -182,7 +184,7 @@ mod tests {
use crate::app::App; use crate::app::App;
use crate::models::BlockSelectionState; use crate::models::BlockSelectionState;
use crate::models::servarr_data::lidarr::lidarr_data::{ use crate::models::servarr_data::lidarr::lidarr_data::{
ActiveLidarrBlock, DELETE_ARTIST_SELECTION_BLOCKS, ActiveLidarrBlock, DELETE_ARTIST_SELECTION_BLOCKS, EDIT_ARTIST_SELECTION_BLOCKS,
}; };
use rstest::rstest; use rstest::rstest;
@@ -263,5 +265,49 @@ mod tests {
insta::assert_snapshot!(output); insta::assert_snapshot!(output);
} }
#[test]
fn test_library_ui_renders_artist_details_over_library() {
let mut app = App::test_default_fully_populated();
app.push_navigation_stack(ActiveLidarrBlock::Artists.into());
app.push_navigation_stack(ActiveLidarrBlock::ArtistDetails.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_edit_artist_over_artist_details() {
let mut app = App::test_default_fully_populated();
app.push_navigation_stack(ActiveLidarrBlock::Artists.into());
app.push_navigation_stack(ActiveLidarrBlock::ArtistDetails.into());
app.push_navigation_stack(ActiveLidarrBlock::EditArtistPrompt.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| {
LibraryUi::draw(f, app, f.area());
});
insta::assert_snapshot!(output);
}
#[test]
fn test_library_ui_renders_dropdown_over_edit_artist_over_artist_details() {
let mut app = App::test_default_fully_populated();
app.push_navigation_stack(ActiveLidarrBlock::Artists.into());
app.push_navigation_stack(ActiveLidarrBlock::ArtistDetails.into());
app.push_navigation_stack(ActiveLidarrBlock::EditArtistPrompt.into());
app.push_navigation_stack(ActiveLidarrBlock::EditArtistSelectMetadataProfile.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| {
LibraryUi::draw(f, app, f.area());
});
insta::assert_snapshot!(output);
}
} }
} }
+4
View File
@@ -1,4 +1,5 @@
use add_artist_ui::AddArtistUi; use add_artist_ui::AddArtistUi;
use artist_details_ui::ArtistDetailsUi;
use delete_artist_ui::DeleteArtistUi; use delete_artist_ui::DeleteArtistUi;
use edit_artist_ui::EditArtistUi; use edit_artist_ui::EditArtistUi;
use ratatui::{ use ratatui::{
@@ -28,6 +29,7 @@ use crate::{
}; };
mod add_artist_ui; mod add_artist_ui;
mod artist_details_ui;
mod delete_artist_ui; mod delete_artist_ui;
mod edit_artist_ui; mod edit_artist_ui;
@@ -43,6 +45,7 @@ impl DrawUi for LibraryUi {
return AddArtistUi::accepts(route) return AddArtistUi::accepts(route)
|| DeleteArtistUi::accepts(route) || DeleteArtistUi::accepts(route)
|| EditArtistUi::accepts(route) || EditArtistUi::accepts(route)
|| ArtistDetailsUi::accepts(route)
|| LIBRARY_BLOCKS.contains(&active_lidarr_block); || LIBRARY_BLOCKS.contains(&active_lidarr_block);
} }
@@ -57,6 +60,7 @@ impl DrawUi for LibraryUi {
_ if AddArtistUi::accepts(route) => AddArtistUi::draw(f, app, area), _ if AddArtistUi::accepts(route) => AddArtistUi::draw(f, app, area),
_ if DeleteArtistUi::accepts(route) => DeleteArtistUi::draw(f, app, area), _ if DeleteArtistUi::accepts(route) => DeleteArtistUi::draw(f, app, area),
_ if EditArtistUi::accepts(route) => EditArtistUi::draw(f, app, area), _ if EditArtistUi::accepts(route) => EditArtistUi::draw(f, app, area),
_ if ArtistDetailsUi::accepts(route) => ArtistDetailsUi::draw(f, app, area),
Route::Lidarr(ActiveLidarrBlock::UpdateAllArtistsPrompt, _) => { Route::Lidarr(ActiveLidarrBlock::UpdateAllArtistsPrompt, _) => {
let confirmation_prompt = ConfirmationPrompt::new() let confirmation_prompt = ConfirmationPrompt::new()
.title("Update All Artists") .title("Update All Artists")
@@ -1,47 +0,0 @@
---
source: src/ui/lidarr_ui/library/add_artist_ui_tests.rs
expression: output
---
╭───────────────────────────────────────────────────── Add Artist ─────────────────────────────────────────────────────╮
│Test Artist │
╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ ╭─────────────── Error ───────────────╮ │
│ │ No artists found matching your query! │ │
│ │ │ │
│ ╰───────────────────────────────────────╯ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
@@ -1,47 +0,0 @@
---
source: src/ui/lidarr_ui/library/add_artist_ui_tests.rs
expression: output
---
╭───────────────────────────────────────────────────── Add Artist ─────────────────────────────────────────────────────╮
│Test Artist │
╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
@@ -1,47 +0,0 @@
---
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 │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
@@ -14,7 +14,7 @@ expression: output
╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ ✔ Name Type Status Rating Genres │ │ ✔ Name Type Status Rating Genres │
│=> Test Artist Person Continuing 8.4 soundtrack │ │=> Test Artist Person Continuing 8.4 soundtrack │
│ │ │ │
│ │ │ │
│ │ │ │
@@ -14,7 +14,7 @@ expression: output
╰──────│ │───────╯ ╰──────│ │───────╯
╭──────│ │───────╮ ╭──────│ │───────╮
│ ✔ │ │ │ │ ✔ │ │ │
│=> │ │ │ │=> │ │ │
│ │ │ │ │ │ │ │
│ │ │ │ │ │ │ │
│ │ ╭─────────────────────────────────────────────────╮ │ │ │ │ ╭─────────────────────────────────────────────────╮ │ │
@@ -14,7 +14,7 @@ expression: output
╰──────│ │───────╯ ╰──────│ │───────╯
╭──────│ │───────╮ ╭──────│ │───────╮
│ ✔ │ │ │ │ ✔ │ │ │
│=> │ │ │ │=> │ │ │
│ │ │ │ │ │ │ │
│ │ │ │ │ │ │ │
│ │ ╭─────────────────────────────────────────────────╮ │ │ │ │ ╭─────────────────────────────────────────────────╮ │ │
@@ -14,7 +14,7 @@ expression: output
╰──────│ │───────╯ ╰──────│ │───────╯
╭──────│ │───────╮ ╭──────│ │───────╮
│ ✔ │ │ │ │ ✔ │ │ │
│=> │ │ │ │=> │ │ │
│ │ │ │ │ │ │ │
│ │ │ │ │ │ │ │
│ │ ╭─────────────────────────────────────────────────╮ │ │ │ │ ╭─────────────────────────────────────────────────╮ │ │
@@ -14,7 +14,7 @@ expression: output
╰──────│ │───────╯ ╰──────│ │───────╯
╭──────│ │───────╮ ╭──────│ │───────╮
│ ✔ │ │ │ │ ✔ │ │ │
│=> │ │ │ │=> │ │ │
│ │ │ │ │ │ │ │
│ │ │ │ │ │ │ │
│ │ ╭─────────────────────────────────────────────────╮ │ │ │ │ ╭─────────────────────────────────────────────────╮ │ │
@@ -14,7 +14,7 @@ expression: output
╰──────│ │───────╯ ╰──────│ │───────╯
╭──────│ │───────╮ ╭──────│ │───────╮
│ ✔ │ │ │ │ ✔ │ │ │
│=> │ │ │ │=> │ │ │
│ │ │ │ │ │ │ │
│ │ │ │ │ │ │ │
│ │ ╭─────────────────────────────────────────────────╮ │ │ │ │ ╭─────────────────────────────────────────────────╮ │ │
@@ -14,7 +14,7 @@ expression: output
╰──────│ │───────╯ ╰──────│ │───────╯
╭──────│ │───────╮ ╭──────│ │───────╮
│ ✔ │ │ │ │ ✔ │ │ │
│=> │ │ │ │=> │ │ │
│ │ │ │ │ │ │ │
│ │ │ │ │ │ │ │
│ │ ╭─────────────────────────────────────────────────╮ │ │ │ │ ╭─────────────────────────────────────────────────╮ │ │
@@ -14,7 +14,7 @@ expression: output
╰──────│ │───────╯ ╰──────│ │───────╯
╭──────│ │───────╮ ╭──────│ │───────╮
│ ✔ │ │ │ │ ✔ │ │ │
│=> │ │ │ │=> │ │ │
│ │ │ │ │ │ │ │
│ │ │ │ │ │ │ │
│ │ ╭─────────────────────────────────────────────────╮ │ │ │ │ ╭─────────────────────────────────────────────────╮ │ │
@@ -14,7 +14,7 @@ expression: output
╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ ✔ Name Type Status Rating Genres │ │ ✔ Name Type Status Rating Genres │
│=> Test Artist Person Continuing 8.4 soundtrack │ │=> Test Artist Person Continuing 8.4 soundtrack │
│ │ │ │
│ │ │ │
│ │ │ │
@@ -0,0 +1,52 @@
---
source: src/ui/lidarr_ui/library/artist_details_ui_tests.rs
expression: output
---
╭ Alex ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│Artist: Alex │
│Overview: some interesting description of the artist │
│Disambiguation: American pianist │
│Type: Person │
│Status: Continuing │
│Genres: soundtrack │
│Rating: 84% │
│Path: /nfs/music/test-artist │
│Quality Profile: Lossless │
│Metadata Profile: Standard │
│Monitored: Yes │
│Albums: 1 │
│Tracks: 15/15 │
│Size on Disk: 0.00 GB │
│ │
│ │
│╭ Artist Details ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮│
││ Albums ││
││─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────││
││ Monitored Title Type Tracks Duration Release Date Size ││
││=> 🏷 Test Album Album 10/10 0 min 2023-01-01 0.00 GB ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
│╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯│
╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
@@ -0,0 +1,52 @@
---
source: src/ui/lidarr_ui/library/artist_details_ui_tests.rs
expression: output
---
╭ Alex ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│Artist: Alex │
│Overview: some interesting description of the artist │
│Disambiguation: American pianist │
│Type: Person │
│Status: Continuing │
│Genres: soundtrack │
│Rating: 84% │
│Path: /nfs/music/test-artist │
│Quality Profile: Lossless │
│Metadata Profile: Standard │
│Monitored: Yes │
│Albums: 1 │
│Tracks: 15/15 ╭──────────────── Automatic Artist Search ────────────────╮ │
│Size on Disk: 0.00 GB │Do you want to trigger an automatic search of your indexers│ │
│ │ for all monitored album(s) for the artist: Alex? │ │
│ │ │ │
│╭ Artist Details ───────────────────────│ │───────────────────────────────────────────╮│
││ Albums │ │ ││
││─────────────────────────────────────────│ │───────────────────────────────────────────││
││ Monitored Title │ │ Release Date Size ││
││=> 🏷 Test Album │ │ 2023-01-01 0.00 GB ││
││ │ │ ││
││ │ │ ││
││ │ │ ││
││ │ │ ││
││ │ │ ││
││ │╭────────────────────────────╮╭───────────────────────────╮│ ││
││ ││ Yes ││ No ││ ││
││ │╰────────────────────────────╯╰───────────────────────────╯│ ││
││ ╰───────────────────────────────────────────────────────────╯ ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
│╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯│
╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
@@ -0,0 +1,52 @@
---
source: src/ui/lidarr_ui/library/artist_details_ui_tests.rs
expression: output
---
╭ Alex ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│Artist: Alex │
│Overview: some interesting description of the artist │
│Disambiguation: American pianist │
│Type: Person │
│Status: Continuing │
│Genres: soundtrack │
│Rating: 84% │
│Path: /nfs/music/test-artist │
│Quality Profile: Lossless │
│Metadata Profile: Standard │
│Monitored: Yes │
│Albums: 1 │
│Tracks: 15/15 │
│Size on Disk: 0.00 GB │
│ │
│ │
│╭ Artist Details ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮│
││ Albums ││
││─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────││
││ Monitored Title Type Tracks Duration Release Date Size ││
││=> 🏷 Test Album Album 10/10 0 min 2023-01-01 0.00 GB ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ╭──────────── Error ─────────────╮ ││
││ │ No items found matching search │ ││
││ ╰──────────────────────────────────╯ ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
│╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯│
╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
@@ -0,0 +1,52 @@
---
source: src/ui/lidarr_ui/library/artist_details_ui_tests.rs
expression: output
---
╭ Alex ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│Artist: Alex │
│Overview: some interesting description of the artist │
│Disambiguation: American pianist │
│Type: Person │
│Status: Continuing │
│Genres: soundtrack │
│Rating: 84% │
│Path: /nfs/music/test-artist │
│Quality Profile: Lossless │
│Metadata Profile: Standard │
│Monitored: Yes │
│Albums: 1 │
│Tracks: 15/15 │
│Size on Disk: 0.00 GB │
│ │
│ │
│╭ Artist Details ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮│
││ Albums ││
││─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────││
││ Monitored Title Type Tracks Duration Release Date Size ││
││=> 🏷 Test Album Album 10/10 0 min 2023-01-01 0.00 GB ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ╭────────────── Search ───────────────╮ ││
││ │album search │ ││
││ ╰───────────────────────────────────────╯ ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
│╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯│
╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
@@ -0,0 +1,52 @@
---
source: src/ui/lidarr_ui/library/artist_details_ui_tests.rs
expression: output
---
╭ Alex ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│Artist: Alex │
│Overview: some interesting description of the artist │
│Disambiguation: American pianist │
│Type: Person │
│Status: Continuing │
│Genres: soundtrack │
│Rating: 84% │
│Path: /nfs/music/test-artist │
│Quality Profile: Lossless │
│Metadata Profile: Standard │
│Monitored: Yes │
│Albums: 1 │
│Tracks: 15/15 ╭──────────────────── Update and Scan ────────────────────╮ │
│Size on Disk: 0.00 GB │ Do you want to trigger an update and disk scan for the │ │
│ │ artist: Alex? │ │
│ │ │ │
│╭ Artist Details ───────────────────────│ │───────────────────────────────────────────╮│
││ Albums │ │ ││
││─────────────────────────────────────────│ │───────────────────────────────────────────││
││ Monitored Title │ │ Release Date Size ││
││=> 🏷 Test Album │ │ 2023-01-01 0.00 GB ││
││ │ │ ││
││ │ │ ││
││ │ │ ││
││ │ │ ││
││ │ │ ││
││ │╭────────────────────────────╮╭───────────────────────────╮│ ││
││ ││ Yes ││ No ││ ││
││ │╰────────────────────────────╯╰───────────────────────────╯│ ││
││ ╰───────────────────────────────────────────────────────────╯ ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
│╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯│
╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
@@ -0,0 +1,52 @@
---
source: src/ui/lidarr_ui/library/artist_details_ui_tests.rs
expression: output
---
╭ Alex ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│Artist: Alex │
│Overview: some interesting description of the artist │
│Disambiguation: American pianist │
│Type: Person │
│Status: Continuing │
│Genres: soundtrack │
│Rating: 84% │
│Path: /nfs/music/test-artist │
│Quality Profile: Lossless │
│Metadata Profile: Standard │
│Monitored: Yes │
│Albums: 1 │
│Tracks: 15/15 ╭──────────────── Automatic Artist Search ────────────────╮ │
│Size on Disk: 0.00 GB │Do you want to trigger an automatic search of your indexers│ │
│ │ for all monitored album(s) for the artist: Alex? │ │
│ │ │ │
│╭ Artist Details ───────────────────────│ │───────────────────────────────────────────╮│
││ Albums │ │ ││
││─────────────────────────────────────────│ │───────────────────────────────────────────││
││ Monitored Title │ │ Release Date Size ││
││=> 🏷 Test Album │ │ 2023-01-01 0.00 GB ││
││ │ │ ││
││ │ │ ││
││ │ │ ││
││ │ │ ││
││ │ │ ││
││ │╭────────────────────────────╮╭───────────────────────────╮│ ││
││ ││ Yes ││ No ││ ││
││ │╰────────────────────────────╯╰───────────────────────────╯│ ││
││ ╰───────────────────────────────────────────────────────────╯ ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
│╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯│
╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
@@ -0,0 +1,52 @@
---
source: src/ui/lidarr_ui/library/artist_details_ui_tests.rs
expression: output
---
╭ Alex ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│Artist: Alex │
│Overview: some interesting description of the artist │
│Disambiguation: American pianist │
│Type: Person │
│Status: Continuing │
│Genres: soundtrack │
│Rating: 84% │
│Path: /nfs/music/test-artist │
│Quality Profile: Lossless │
│Metadata Profile: Standard │
│Monitored: Yes │
│Albums: 1 │
│Tracks: 15/15 ╭──────────────────── Update and Scan ────────────────────╮ │
│Size on Disk: 0.00 GB │ Do you want to trigger an update and disk scan for the │ │
│ │ artist: Alex? │ │
│ │ │ │
│╭ Artist Details ───────────────────────│ │───────────────────────────────────────────╮│
││ Albums │ │ ││
││─────────────────────────────────────────│ │───────────────────────────────────────────││
││ Monitored Title │ │ Release Date Size ││
││=> 🏷 Test Album │ │ 2023-01-01 0.00 GB ││
││ │ │ ││
││ │ │ ││
││ │ │ ││
││ │ │ ││
││ │ │ ││
││ │╭────────────────────────────╮╭───────────────────────────╮│ ││
││ ││ Yes ││ No ││ ││
││ │╰────────────────────────────╯╰───────────────────────────╯│ ││
││ ╰───────────────────────────────────────────────────────────╯ ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
│╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯│
╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
@@ -0,0 +1,52 @@
---
source: src/ui/lidarr_ui/library/artist_details_ui_tests.rs
expression: output
---
╭ Alex ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│Artist: Alex │
│Overview: some interesting description of the artist │
│Disambiguation: American pianist │
│Type: Person │
│Status: Continuing │
│Genres: soundtrack │
│Rating: 84% │
│Path: /nfs/music/test-artist │
│Quality Profile: Lossless │
│Metadata Profile: Standard │
│Monitored: Yes │
│Albums: 1 │
│Tracks: 15/15 │
│Size on Disk: 0.00 GB │
│ │
│ │
│╭ Artist Details ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮│
││ Albums ││
││─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
│╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯│
╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
@@ -0,0 +1,52 @@
---
source: src/ui/lidarr_ui/library/artist_details_ui_tests.rs
expression: output
---
╭ Alex ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│Artist: Alex │
│Overview: some interesting description of the artist │
│Disambiguation: American pianist │
│Type: Person │
│Status: Continuing │
│Genres: soundtrack │
│Rating: 84% │
│Path: /nfs/music/test-artist │
│Quality Profile: Lossless │
│Metadata Profile: Standard │
│Monitored: Yes │
│Albums: 1 │
│Tracks: 15/15 │
│Size on Disk: 0.00 GB │
│ │
│ │
│╭ Artist Details ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮│
││ Albums ││
││─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────││
││ ││
││ ││
││ Loading ... ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
│╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯│
╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
@@ -20,7 +20,7 @@ expression: output
╭───────────────────── Delete Artist ─────────────────────╮ ╭───────────────────── Delete Artist ─────────────────────╮
│ Do you really want to delete the artist: │ │ Do you really want to delete the artist: │
? Alex?
│ │ │ │
│ │ │ │
│ ╭───╮ │ │ ╭───╮ │
@@ -8,7 +8,7 @@ expression: output
╭─────────────────────────────────────────────── Edit - ───────────────────────────────────────────────╮ ╭─────────────────────────────────── Edit - Alex (American pianist) ────────────────────────────────────╮
│ │ │ │
│ │ │ │
│ │ │ │
@@ -8,7 +8,7 @@ expression: output
╭─────────────────────────────────────────────── Edit - ───────────────────────────────────────────────╮ ╭─────────────────────────────────── Edit - Alex (American pianist) ────────────────────────────────────╮
│ │ │ │
│ │ │ │
│ │ │ │
@@ -8,7 +8,7 @@ expression: output
╭─────────────────────────────────────────────── Edit - ───────────────────────────────────────────────╮ ╭─────────────────────────────────── Edit - Alex (American pianist) ────────────────────────────────────╮
│ │ │ │
│ │ │ │
│ │ │ │
@@ -8,7 +8,7 @@ expression: output
╭─────────────────────────────────────────────── Edit - ───────────────────────────────────────────────╮ ╭─────────────────────────────────── Edit - Alex (American pianist) ────────────────────────────────────╮
│ │ │ │
│ │ │ │
│ │ │ │
@@ -8,7 +8,7 @@ expression: output
╭─────────────────────────────────────────────── Edit - ───────────────────────────────────────────────╮ ╭─────────────────────────────────── Edit - Alex (American pianist) ────────────────────────────────────╮
│ │ │ │
│ │ │ │
│ │ │ │
@@ -0,0 +1,52 @@
---
source: src/ui/lidarr_ui/library/library_ui_tests.rs
expression: output
---
─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
Name ▼ Type Status Quality Profile Metadata Profile Albums Tracks Size Monitored Tags
=> Alex Person Continuing Lossless Standard 1 15/15 0.00 GB 🏷 alex
╭ Alex ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│Artist: Alex │
│Overview: some interesting description of the artist │
│Disambiguation: American pianist │
│Type: Person │
│Status: Continuing │
│Genres: soundtrack │
│Rating: 84% │
│Path: /nfs/music/test-artist │
│Quality Profile: Lossless │
│Metadata Profile: Standard │
│Monitored: Yes │
│Albums: 1 │
│Tracks: 15/15 │
│Size on Disk: 0.00 GB │
│ │
│ │
│╭ Artist Details ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮│
││ Albums ││
││─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────││
││ Monitored Title Type Tracks Duration Release Date Size ││
││=> 🏷 Test Album Album 10/10 0 min 2023-01-01 0.00 GB ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
│╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯│
╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
@@ -4,7 +4,7 @@ expression: output
--- ---
───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── ─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
Name ▼ Type Status Quality Profile Metadata Profile Albums Tracks Size Monitored Tags Name ▼ Type Status Quality Profile Metadata Profile Albums Tracks Size Monitored Tags
=> Continuing 0 0.00 GB => Alex Person Continuing Lossless Standard 1 15/15 0.00 GB 🏷 alex
@@ -20,7 +20,7 @@ expression: output
╭───────────────────── Delete Artist ─────────────────────╮ ╭───────────────────── Delete Artist ─────────────────────╮
│ Do you really want to delete the artist: │ │ Do you really want to delete the artist: │
? Alex?
│ │ │ │
│ │ │ │
│ ╭───╮ │ │ ╭───╮ │
@@ -0,0 +1,48 @@
---
source: src/ui/lidarr_ui/library/library_ui_tests.rs
expression: output
---
─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
Name ▼ Type Status Quality Profile Metadata Profile Albums Tracks Size Monitored Tags
=> Alex Person Continuing Lossless Standard 1 15/15 0.00 GB 🏷 alex
╭─────────────────────────────────── Edit - Alex (American pianist) ────────────────────────────────────╮
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ ╭───╮ │
│ Monitored: │ ✔ │ │
│ ╰───╯ │
│ ╭───────────────────────────────╮──────────────────────────────╮ │
│ Monitor│Standard │ ▼ │ │
│ │ │──────────────────────────────╯ │
│ │ │──────────────────────────────╮ │
│ Qual│ │ ▼ │ │
│ │ │──────────────────────────────╯ │
│ │ │──────────────────────────────╮ │
│ Metad│ │ ▼ │ │
│ │ │──────────────────────────────╯ │
│ │ │──────────────────────────────╮ │
│ │ │ │ │
│ │ │──────────────────────────────╯ │
│ │ │──────────────────────────────╮ │
│ │ │ │ │
│ ╰───────────────────────────────╯──────────────────────────────╯ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│╭───────────────────────────────────────────────────╮╭──────────────────────────────────────────────────╮│
││ Save ││ Cancel ││
│╰───────────────────────────────────────────────────╯╰──────────────────────────────────────────────────╯│
╰─────────────────────────────────────────────────────────────────────────────────────────────────────────╯
@@ -0,0 +1,48 @@
---
source: src/ui/lidarr_ui/library/library_ui_tests.rs
expression: output
---
─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
Name ▼ Type Status Quality Profile Metadata Profile Albums Tracks Size Monitored Tags
=> Alex Person Continuing Lossless Standard 1 15/15 0.00 GB 🏷 alex
╭─────────────────────────────────── Edit - Alex (American pianist) ────────────────────────────────────╮
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ ╭───╮ │
│ Monitored: │ ✔ │ │
│ ╰───╯ │
│ ╭─────────────────────────────────────────────────╮ │
│ Monitor New Albums: │All Albums ▼ │ │
│ ╰─────────────────────────────────────────────────╯ │
│ ╭─────────────────────────────────────────────────╮ │
│ Quality Profile: │Lossless ▼ │ │
│ ╰─────────────────────────────────────────────────╯ │
│ ╭─────────────────────────────────────────────────╮ │
│ Metadata Profile: │Standard ▼ │ │
│ ╰─────────────────────────────────────────────────╯ │
│ ╭─────────────────────────────────────────────────╮ │
│ Path: │/nfs/music │ │
│ ╰─────────────────────────────────────────────────╯ │
│ ╭─────────────────────────────────────────────────╮ │
│ Tags: │alex │ │
│ ╰─────────────────────────────────────────────────╯ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│╭───────────────────────────────────────────────────╮╭──────────────────────────────────────────────────╮│
││ Save ││ Cancel ││
│╰───────────────────────────────────────────────────╯╰──────────────────────────────────────────────────╯│
╰─────────────────────────────────────────────────────────────────────────────────────────────────────────╯
@@ -4,7 +4,7 @@ expression: output
--- ---
───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── ─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
Name ▼ Type Status Quality Profile Metadata Profile Albums Tracks Size Monitored Tags Name ▼ Type Status Quality Profile Metadata Profile Albums Tracks Size Monitored Tags
=> Continuing 0 0.00 GB => Alex Person Continuing Lossless Standard 1 15/15 0.00 GB 🏷 alex
@@ -4,4 +4,4 @@ expression: output
--- ---
───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── ─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
Name ▼ Type Status Quality Profile Metadata Profile Albums Tracks Size Monitored Tags Name ▼ Type Status Quality Profile Metadata Profile Albums Tracks Size Monitored Tags
=> Continuing 0 0.00 GB => Alex Person Continuing Lossless Standard 1 15/15 0.00 GB 🏷 alex
@@ -4,7 +4,7 @@ expression: output
--- ---
───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── ─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
Name Type Status Quality Profile Metadata Profile Albums Tracks Size Monitored Tags Name Type Status Quality Profile Metadata Profile Albums Tracks Size Monitored Tags
=> Continuing 0 0.00 GB => Alex Person Continuing Lossless Standard 1 15/15 0.00 GB 🏷 alex
@@ -4,7 +4,7 @@ expression: output
--- ---
───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── ─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
Name ▼ Type Status Quality Profile Metadata Profile Albums Tracks Size Monitored Tags Name ▼ Type Status Quality Profile Metadata Profile Albums Tracks Size Monitored Tags
=> Continuing 0 0.00 GB => Alex Person Continuing Lossless Standard 1 15/15 0.00 GB 🏷 alex
@@ -4,7 +4,7 @@ expression: output
--- ---
───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── ─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
Name ▼ Type Status Quality Profile Metadata Profile Albums Tracks Size Monitored Tags Name ▼ Type Status Quality Profile Metadata Profile Albums Tracks Size Monitored Tags
=> Continuing 0 0.00 GB => Alex Person Continuing Lossless Standard 1 15/15 0.00 GB 🏷 alex
@@ -4,7 +4,7 @@ expression: output
--- ---
───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── ─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
Name ▼ Type Status Quality Profile Metadata Profile Albums Tracks Size Monitored Tags Name ▼ Type Status Quality Profile Metadata Profile Albums Tracks Size Monitored Tags
=> Continuing 0 0.00 GB => Alex Person Continuing Lossless Standard 1 15/15 0.00 GB 🏷 alex
@@ -4,7 +4,7 @@ expression: output
--- ---
───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── ─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
Name ▼ Type Status Quality Profile Metadata Profile Albums Tracks Size Monitored Tags Name ▼ Type Status Quality Profile Metadata Profile Albums Tracks Size Monitored Tags
=> Continuing 0 0.00 GB => Alex Person Continuing Lossless Standard 1 15/15 0.00 GB 🏷 alex