feat: Added CLI and TUI support for editing Lidarr artists

This commit is contained in:
2026-01-07 12:01:03 -07:00
parent 3c1634d1e3
commit b1afdaf541
49 changed files with 2338 additions and 296 deletions
@@ -1,11 +1,16 @@
#[cfg(test)]
mod tests {
use crate::models::lidarr_models::{Artist, DeleteArtistParams, LidarrSerdeable};
use crate::models::lidarr_models::{
Artist, DeleteArtistParams, EditArtistParams, LidarrSerdeable, NewItemMonitorType,
};
use crate::network::NetworkResource;
use crate::network::lidarr_network::LidarrEvent;
use crate::network::network_tests::test_utils::{MockServarrApi, test_network};
use bimap::BiMap;
use mockito::Matcher;
use pretty_assertions::assert_eq;
use serde_json::json;
use serde_json::{Value, json};
use crate::network::lidarr_network::lidarr_network_test_utils::test_utils::ARTIST_JSON;
#[tokio::test]
async fn test_handle_list_artists_event() {
@@ -70,36 +75,9 @@ mod tests {
#[tokio::test]
async fn test_handle_get_artist_details_event() {
let artist_json = json!({
"id": 1,
"artistName": "Test Artist",
"foreignArtistId": "test-foreign-id",
"status": "continuing",
"overview": "some interesting description of the artist",
"artistType": "Person",
"disambiguation": "American pianist",
"path": "/music/test-artist",
"members": [{"name": "alex", "instrument": "piano"}],
"qualityProfileId": 1,
"metadataProfileId": 1,
"monitored": true,
"monitorNewItems": "all",
"genres": ["soundtrack"],
"tags": [1],
"added": "2023-01-01T00:00:00Z",
"ratings": { "votes": 15, "value": 8.4 },
"statistics": {
"albumCount": 1,
"trackFileCount": 15,
"trackCount": 15,
"totalTrackCount": 15,
"sizeOnDisk": 12345,
"percentOfTracks": 99.9
}
});
let response: Artist = serde_json::from_value(artist_json.clone()).unwrap();
let expected_artist: Artist = serde_json::from_str(ARTIST_JSON).unwrap();
let (mock, app, _server) = MockServarrApi::get()
.returns(artist_json)
.returns(serde_json::from_str(ARTIST_JSON).unwrap())
.path("/1")
.build_for(LidarrEvent::GetArtistDetails(1))
.await;
@@ -116,7 +94,7 @@ mod tests {
panic!("Expected Artist");
};
assert_eq!(artist, response);
assert_eq!(artist, expected_artist);
}
#[tokio::test]
@@ -184,4 +162,198 @@ mod tests {
mock.assert_async().await;
}
#[tokio::test]
async fn test_handle_edit_artist_event() {
let mut expected_body: Value = serde_json::from_str(ARTIST_JSON).unwrap();
*expected_body.get_mut("monitored").unwrap() = json!(false);
*expected_body.get_mut("monitorNewItems").unwrap() = json!("none");
*expected_body.get_mut("qualityProfileId").unwrap() = json!(1111);
*expected_body.get_mut("metadataProfileId").unwrap() = json!(2222);
*expected_body.get_mut("path").unwrap() = json!("/nfs/Test Path");
*expected_body.get_mut("tags").unwrap() = json!([1, 2]);
let edit_artist_params = EditArtistParams {
artist_id: 1,
monitored: Some(false),
monitor_new_items: Some(NewItemMonitorType::None),
quality_profile_id: Some(1111),
metadata_profile_id: Some(2222),
root_folder_path: Some("/nfs/Test Path".to_owned()),
tag_input_string: Some("usenet, testing".to_owned()),
..EditArtistParams::default()
};
let (async_details_server, app, mut server) = MockServarrApi::get()
.returns(serde_json::from_str(ARTIST_JSON).unwrap())
.path("/1")
.build_for(LidarrEvent::GetArtistDetails(1))
.await;
let async_edit_server = server
.mock(
"PUT",
format!(
"/api/v1{}/1",
LidarrEvent::EditArtist(edit_artist_params.clone()).resource()
)
.as_str(),
)
.with_status(202)
.match_header("X-Api-Key", "test1234")
.match_body(Matcher::Json(expected_body))
.create_async()
.await;
app.lock().await.data.lidarr_data.tags_map =
BiMap::from_iter([(1, "usenet".to_owned()), (2, "testing".to_owned())]);
app.lock().await.server_tabs.set_index(2);
let mut network = test_network(&app);
assert!(
network
.handle_lidarr_event(LidarrEvent::EditArtist(edit_artist_params))
.await
.is_ok()
);
async_details_server.assert_async().await;
async_edit_server.assert_async().await;
}
#[tokio::test]
async fn test_handle_edit_artist_event_does_not_overwrite_tag_ids_vec_when_tag_input_string_is_none()
{
let mut expected_body: Value = serde_json::from_str(ARTIST_JSON).unwrap();
*expected_body.get_mut("monitored").unwrap() = json!(false);
*expected_body.get_mut("monitorNewItems").unwrap() = json!("none");
*expected_body.get_mut("qualityProfileId").unwrap() = json!(1111);
*expected_body.get_mut("metadataProfileId").unwrap() = json!(2222);
*expected_body.get_mut("path").unwrap() = json!("/nfs/Test Path");
*expected_body.get_mut("tags").unwrap() = json!([1, 2]);
let edit_artist_params = EditArtistParams {
artist_id: 1,
monitored: Some(false),
monitor_new_items: Some(NewItemMonitorType::None),
quality_profile_id: Some(1111),
metadata_profile_id: Some(2222),
root_folder_path: Some("/nfs/Test Path".to_owned()),
tags: Some(vec![1, 2]),
..EditArtistParams::default()
};
let (async_details_server, app, mut server) = MockServarrApi::get()
.returns(serde_json::from_str(ARTIST_JSON).unwrap())
.path("/1")
.build_for(LidarrEvent::GetArtistDetails(1))
.await;
let async_edit_server = server
.mock(
"PUT",
format!(
"/api/v1{}/1",
LidarrEvent::EditArtist(edit_artist_params.clone()).resource()
)
.as_str(),
)
.with_status(202)
.match_header("X-Api-Key", "test1234")
.match_body(Matcher::Json(expected_body))
.create_async()
.await;
app.lock().await.data.lidarr_data.tags_map =
BiMap::from_iter([(1, "usenet".to_owned()), (2, "testing".to_owned())]);
app.lock().await.server_tabs.set_index(2);
let mut network = test_network(&app);
assert!(
network
.handle_lidarr_event(LidarrEvent::EditArtist(edit_artist_params))
.await
.is_ok()
);
async_details_server.assert_async().await;
async_edit_server.assert_async().await;
}
#[tokio::test]
async fn test_handle_edit_artist_event_defaults_to_previous_values() {
let edit_artist_params = EditArtistParams {
artist_id: 1,
..EditArtistParams::default()
};
let expected_body: Value = serde_json::from_str(ARTIST_JSON).unwrap();
let (async_details_server, app, mut server) = MockServarrApi::get()
.returns(serde_json::from_str(ARTIST_JSON).unwrap())
.path("/1")
.build_for(LidarrEvent::GetArtistDetails(1))
.await;
let async_edit_server = server
.mock(
"PUT",
format!(
"/api/v1{}/1",
LidarrEvent::EditArtist(edit_artist_params.clone()).resource()
)
.as_str(),
)
.with_status(202)
.match_header("X-Api-Key", "test1234")
.match_body(Matcher::Json(expected_body))
.create_async()
.await;
app.lock().await.server_tabs.set_index(2);
let mut network = test_network(&app);
assert!(
network
.handle_lidarr_event(LidarrEvent::EditArtist(edit_artist_params))
.await
.is_ok()
);
async_details_server.assert_async().await;
async_edit_server.assert_async().await;
}
#[tokio::test]
async fn test_handle_edit_artist_event_returns_empty_tags_vec_when_clear_tags_is_true() {
let mut expected_body: Value = serde_json::from_str(ARTIST_JSON).unwrap();
*expected_body.get_mut("tags").unwrap() = json!([]);
let (async_details_server, app, mut server) = MockServarrApi::get()
.returns(serde_json::from_str(ARTIST_JSON).unwrap())
.path("/1")
.build_for(LidarrEvent::GetArtistDetails(1))
.await;
let edit_artist_params = EditArtistParams {
artist_id: 1,
clear_tags: true,
..EditArtistParams::default()
};
let async_edit_server = server
.mock(
"PUT",
format!(
"/api/v1{}/1",
LidarrEvent::EditArtist(edit_artist_params.clone()).resource()
)
.as_str(),
)
.with_status(202)
.match_header("X-Api-Key", "test1234")
.match_body(Matcher::Json(expected_body))
.create_async()
.await;
app.lock().await.server_tabs.set_index(2);
let mut network = test_network(&app);
assert!(
network
.handle_lidarr_event(LidarrEvent::EditArtist(edit_artist_params))
.await
.is_ok()
);
async_details_server.assert_async().await;
async_edit_server.assert_async().await;
}
}
@@ -1,132 +1,177 @@
#[cfg(test)]
#[allow(dead_code)] // TODO: maybe remove?
pub mod test_utils {
use crate::models::lidarr_models::{Artist, ArtistStatistics, ArtistStatus, DownloadRecord, DownloadStatus, DownloadsResponse, Member, MetadataProfile, NewItemMonitorType, Ratings, SystemStatus};
use crate::models::servarr_models::{QualityProfile, RootFolder, Tag};
use crate::models::HorizontallyScrollableText;
use bimap::BiMap;
use chrono::DateTime;
use serde_json::Number;
use crate::models::HorizontallyScrollableText;
use crate::models::lidarr_models::{
Artist, ArtistStatistics, ArtistStatus, DownloadRecord, DownloadStatus, DownloadsResponse,
EditArtistParams, Member, MetadataProfile, NewItemMonitorType, Ratings, SystemStatus,
};
use crate::models::servarr_models::{QualityProfile, RootFolder, Tag};
use bimap::BiMap;
use chrono::DateTime;
use serde_json::Number;
pub fn member() -> Member {
Member {
name: Some("alex".to_owned()),
instrument: Some("piano".to_owned())
}
}
pub const ARTIST_JSON: &str = r#"{
"id": 1,
"artistName": "Test Artist",
"foreignArtistId": "test-foreign-id",
"status": "continuing",
"overview": "some interesting description of the artist",
"artistType": "Person",
"disambiguation": "American pianist",
"path": "/music/test-artist",
"members": [{"name": "alex", "instrument": "piano"}],
"qualityProfileId": 1,
"metadataProfileId": 1,
"monitored": true,
"monitorNewItems": "all",
"genres": ["soundtrack"],
"tags": [1],
"added": "2023-01-01T00:00:00Z",
"ratings": { "votes": 15, "value": 8.4 },
"statistics": {
"albumCount": 1,
"trackFileCount": 15,
"trackCount": 15,
"totalTrackCount": 15,
"sizeOnDisk": 12345,
"percentOfTracks": 99.9
}
}"#;
pub fn ratings() -> Ratings {
Ratings {
votes: 15,
value: 8.4
}
}
pub fn member() -> Member {
Member {
name: Some("alex".to_owned()),
instrument: Some("piano".to_owned()),
}
}
pub fn artist_statistics() -> ArtistStatistics {
ArtistStatistics {
album_count: 1,
track_file_count: 15,
track_count: 15,
total_track_count: 15,
size_on_disk: 12345,
percent_of_tracks: 99.9
}
}
pub fn ratings() -> Ratings {
Ratings {
votes: 15,
value: 8.4,
}
}
pub fn artist() -> Artist {
Artist {
id: 1,
artist_name: "Alex".into(),
foreign_artist_id: "test-foreign-id".to_owned(),
status: ArtistStatus::Continuing,
overview: Some("some interesting description of the artist".to_owned()),
artist_type: Some("Person".to_owned()),
disambiguation: Some("American pianist".to_owned()),
members: Some(vec![member()]),
path: "/nfs/music/test-artist".to_owned(),
quality_profile_id: quality_profile().id,
metadata_profile_id: metadata_profile().id,
monitored: true,
monitor_new_items: NewItemMonitorType::All,
genres: vec!["soundtrack".to_owned()],
tags: vec![Number::from(tag().id)],
added: DateTime::from(DateTime::parse_from_rfc3339("2023-01-01T00:00:00Z").unwrap()),
ratings: Some(ratings()),
statistics: Some(artist_statistics())
}
}
pub fn artist_statistics() -> ArtistStatistics {
ArtistStatistics {
album_count: 1,
track_file_count: 15,
track_count: 15,
total_track_count: 15,
size_on_disk: 12345,
percent_of_tracks: 99.9,
}
}
pub fn quality_profile() -> QualityProfile {
QualityProfile {
id: 1,
name: "Lossless".to_owned()
}
}
pub fn artist() -> Artist {
Artist {
id: 1,
artist_name: "Alex".into(),
foreign_artist_id: "test-foreign-id".to_owned(),
status: ArtistStatus::Continuing,
overview: Some("some interesting description of the artist".to_owned()),
artist_type: Some("Person".to_owned()),
disambiguation: Some("American pianist".to_owned()),
members: Some(vec![member()]),
path: "/nfs/music/test-artist".to_owned(),
quality_profile_id: quality_profile().id,
metadata_profile_id: metadata_profile().id,
monitored: true,
monitor_new_items: NewItemMonitorType::All,
genres: vec!["soundtrack".to_owned()],
tags: vec![Number::from(tag().id)],
added: DateTime::from(DateTime::parse_from_rfc3339("2023-01-01T00:00:00Z").unwrap()),
ratings: Some(ratings()),
statistics: Some(artist_statistics()),
}
}
pub fn quality_profile_map() -> BiMap<i64, String> {
let quality_profile = quality_profile();
BiMap::from_iter(vec![(quality_profile.id, quality_profile.name)])
}
pub fn quality_profile() -> QualityProfile {
QualityProfile {
id: 1,
name: "Lossless".to_owned(),
}
}
pub fn metadata_profile() -> MetadataProfile {
MetadataProfile {
id: 1,
name: "Standard".to_owned()
}
}
pub fn quality_profile_map() -> BiMap<i64, String> {
let quality_profile = quality_profile();
BiMap::from_iter(vec![(quality_profile.id, quality_profile.name)])
}
pub fn metadata_profile_map() -> BiMap<i64, String> {
let metadata_profile = metadata_profile();
BiMap::from_iter(vec![(metadata_profile.id, metadata_profile.name)])
}
pub fn metadata_profile() -> MetadataProfile {
MetadataProfile {
id: 1,
name: "Standard".to_owned(),
}
}
pub fn tag() -> Tag {
Tag {
id: 1,
label: "alex".to_owned()
}
}
pub fn metadata_profile_map() -> BiMap<i64, String> {
let metadata_profile = metadata_profile();
BiMap::from_iter(vec![(metadata_profile.id, metadata_profile.name)])
}
pub fn tags_map() -> BiMap<i64, String> {
let tag = tag();
BiMap::from_iter(vec![(tag.id, tag.label)])
}
pub fn tag() -> Tag {
Tag {
id: 1,
label: "alex".to_owned(),
}
}
pub fn download_record() -> DownloadRecord {
DownloadRecord {
title: "Test download title".to_owned(),
status: DownloadStatus::Downloading,
id: 1,
album_id: Some(Number::from(1i64)),
artist_id: Some(Number::from(1i64)),
size: 3543348019f64,
sizeleft: 1771674009f64,
output_path: Some(HorizontallyScrollableText::from("/nfs/music/alex/album")),
indexer: "kickass torrents".to_owned(),
download_client: Some("transmission".to_owned())
}
}
pub fn tags_map() -> BiMap<i64, String> {
let tag = tag();
BiMap::from_iter(vec![(tag.id, tag.label)])
}
pub fn downloads_response() -> DownloadsResponse {
DownloadsResponse {
records: vec![download_record()]
}
}
pub fn system_status() -> SystemStatus {
SystemStatus {
version: "1.0".to_owned(),
start_time: DateTime::from(DateTime::parse_from_rfc3339("2023-01-01T00:00:00Z").unwrap()),
}
}
pub fn root_folder() -> RootFolder {
RootFolder {
id: 1,
path: "/nfs".to_owned(),
accessible: true,
free_space: 219902325555200,
unmapped_folders: None,
}
}
}
pub fn download_record() -> DownloadRecord {
DownloadRecord {
title: "Test download title".to_owned(),
status: DownloadStatus::Downloading,
id: 1,
album_id: Some(Number::from(1i64)),
artist_id: Some(Number::from(1i64)),
size: 3543348019f64,
sizeleft: 1771674009f64,
output_path: Some(HorizontallyScrollableText::from("/nfs/music/alex/album")),
indexer: "kickass torrents".to_owned(),
download_client: Some("transmission".to_owned()),
}
}
pub fn downloads_response() -> DownloadsResponse {
DownloadsResponse {
records: vec![download_record()],
}
}
pub fn system_status() -> SystemStatus {
SystemStatus {
version: "1.0".to_owned(),
start_time: DateTime::from(DateTime::parse_from_rfc3339("2023-01-01T00:00:00Z").unwrap()),
}
}
pub fn root_folder() -> RootFolder {
RootFolder {
id: 1,
path: "/nfs".to_owned(),
accessible: true,
free_space: 219902325555200,
unmapped_folders: None,
}
}
pub fn edit_artist_params() -> EditArtistParams {
EditArtistParams {
artist_id: artist().id,
monitored: Some(true),
monitor_new_items: Some(NewItemMonitorType::All),
quality_profile_id: Some(quality_profile().id),
metadata_profile_id: Some(metadata_profile().id),
root_folder_path: Some("/nfs/music/test-artist".to_owned()),
tags: Some(vec![tag().id]),
tag_input_string: Some("alex".to_owned()),
clear_tags: false,
}
}
}
@@ -1,12 +1,17 @@
#[cfg(test)]
mod tests {
use std::sync::Arc;
use crate::models::lidarr_models::{LidarrSerdeable, MetadataProfile};
use crate::models::servarr_models::{QualityProfile, Tag};
use crate::network::network_tests::test_utils::{MockServarrApi, test_network};
use crate::network::{NetworkEvent, NetworkResource, lidarr_network::LidarrEvent};
use bimap::BiMap;
use pretty_assertions::{assert_eq, assert_str_eq};
use rstest::rstest;
use serde_json::json;
use tokio::sync::Mutex;
use crate::app::App;
use crate::models::servarr_data::lidarr::modals::EditArtistModal;
#[rstest]
fn test_resource_artist(
@@ -170,4 +175,91 @@ mod tests {
Some(&"usenet".to_owned())
);
}
#[tokio::test]
async fn test_handle_add_lidarr_tag_event() {
let tag_json = json!({
"id": 1,
"label": "usenet"
});
let response: Tag = serde_json::from_value(tag_json.clone()).unwrap();
let (mock, app, _server) = MockServarrApi::post()
.with_request_body(json!({ "label": "usenet" }))
.returns(tag_json)
.build_for(LidarrEvent::AddTag("usenet".to_owned()))
.await;
app.lock().await.server_tabs.set_index(2);
let mut network = test_network(&app);
let result = network
.handle_lidarr_event(LidarrEvent::AddTag("usenet".to_owned()))
.await;
mock.assert_async().await;
let LidarrSerdeable::Tag(tag) = result.unwrap() else {
panic!("Expected Tag");
};
assert_eq!(tag, response);
assert_eq!(
app.lock().await.data.lidarr_data.tags_map.get_by_left(&1),
Some(&"usenet".to_owned())
);
}
#[tokio::test]
async fn test_extract_and_add_lidarr_tag_ids_vec() {
let app_arc = Arc::new(Mutex::new(App::test_default()));
let tags = " test,HI ,, usenet ";
{
let mut app = app_arc.lock().await;
app.data.lidarr_data.tags_map = BiMap::from_iter([
(1, "usenet".to_owned()),
(2, "test".to_owned()),
(3, "hi".to_owned()),
]);
}
app_arc.lock().await.server_tabs.set_index(2);
let mut network = test_network(&app_arc);
assert_eq!(
network.extract_and_add_lidarr_tag_ids_vec(tags).await,
vec![2, 3, 1]
);
}
#[tokio::test]
async fn test_extract_and_add_lidarr_tag_ids_vec_add_missing_tags_first() {
let (mock, app, _server) = MockServarrApi::post()
.with_request_body(json!({ "label": "TESTING" }))
.returns(json!({ "id": 3, "label": "testing" }))
.build_for(LidarrEvent::GetTags)
.await;
let tags = "usenet, test, TESTING";
{
let mut app_guard = app.lock().await;
app_guard.data.lidarr_data.edit_artist_modal = Some(EditArtistModal {
tags: tags.into(),
..EditArtistModal::default()
});
app_guard.data.lidarr_data.tags_map =
BiMap::from_iter([(1, "usenet".to_owned()), (2, "test".to_owned())]);
}
app.lock().await.server_tabs.set_index(2);
let mut network = test_network(&app);
let tag_ids_vec = network.extract_and_add_lidarr_tag_ids_vec(tags).await;
mock.assert_async().await;
assert_eq!(tag_ids_vec, vec![1, 2, 3]);
assert_eq!(
app.lock().await.data.lidarr_data.tags_map,
BiMap::from_iter([
(1, "usenet".to_owned()),
(2, "test".to_owned()),
(3, "testing".to_owned())
])
);
}
}