feat: Added TUI and CLI support for viewing Artist history in Lidarr

This commit is contained in:
2026-01-14 16:09:37 -07:00
parent 8b9467bd39
commit d7f0dd5950
66 changed files with 1843 additions and 256 deletions
@@ -95,7 +95,7 @@ mod tests {
mock.assert_async().await;
assert!(result.is_ok());
let LidarrSerdeable::HistoryWrapper(history) = result.unwrap() else {
let LidarrSerdeable::LidarrHistoryWrapper(history) = result.unwrap() else {
panic!("Expected LidarrHistoryWrapper")
};
assert_eq!(
@@ -165,7 +165,7 @@ mod tests {
app.lock().await.server_tabs.set_index(2);
let mut network = test_network(&app);
let LidarrSerdeable::HistoryWrapper(history) = network
let LidarrSerdeable::LidarrHistoryWrapper(history) = network
.handle_lidarr_event(LidarrEvent::GetHistory(500))
.await
.unwrap()
@@ -2,18 +2,20 @@
mod tests {
use crate::models::lidarr_models::{
AddArtistBody, AddArtistOptions, AddArtistSearchResult, Artist, DeleteParams, EditArtistParams,
LidarrSerdeable, MonitorType, NewItemMonitorType,
LidarrHistoryItem, LidarrSerdeable, MonitorType, NewItemMonitorType,
};
use crate::models::servarr_data::lidarr::lidarr_data::ActiveLidarrBlock;
use crate::models::stateful_table::{SortOption, StatefulTable};
use crate::network::NetworkResource;
use crate::network::lidarr_network::LidarrEvent;
use crate::network::lidarr_network::lidarr_network_test_utils::test_utils::{
ADD_ARTIST_SEARCH_RESULT_JSON, ARTIST_JSON,
ADD_ARTIST_SEARCH_RESULT_JSON, ARTIST_JSON, artist, lidarr_history_item,
};
use crate::network::network_tests::test_utils::{MockServarrApi, test_network};
use bimap::BiMap;
use mockito::Matcher;
use pretty_assertions::assert_eq;
use rstest::rstest;
use serde_json::{Value, json};
#[tokio::test]
@@ -101,6 +103,296 @@ mod tests {
assert_eq!(artist, expected_artist);
}
#[rstest]
#[tokio::test]
async fn test_handle_get_lidarr_artist_history_event(
#[values(true, false)] use_custom_sorting: bool,
) {
let history_json = json!([{
"id": 123,
"sourceTitle": "z album",
"albumId": 1007,
"artistId": 1007,
"quality": { "quality": { "name": "Lossless" } },
"date": "2023-01-01T00:00:00Z",
"eventType": "grabbed",
"data": {
"droppedPath": "/nfs/nzbget/completed/music/Something/cool.mp3",
"importedPath": "/nfs/music/Something/Album 1/Cool.mp3"
}
},
{
"id": 456,
"sourceTitle": "An Album",
"albumId": 2001,
"artistId": 2001,
"quality": { "quality": { "name": "Lossless" } },
"date": "2023-01-01T00:00:00Z",
"eventType": "grabbed",
"data": {
"droppedPath": "/nfs/nzbget/completed/music/Something/cool.mp3",
"importedPath": "/nfs/music/Something/Album 1/Cool.mp3"
}
}]);
let response: Vec<LidarrHistoryItem> = serde_json::from_value(history_json.clone()).unwrap();
let mut expected_history_items = vec![
LidarrHistoryItem {
id: 123,
artist_id: 1007,
album_id: 1007,
source_title: "z album".into(),
..lidarr_history_item()
},
LidarrHistoryItem {
id: 456,
artist_id: 2001,
album_id: 2001,
source_title: "An Album".into(),
..lidarr_history_item()
},
];
let (async_server, app, _server) = MockServarrApi::get()
.returns(history_json)
.query("artistId=1")
.build_for(LidarrEvent::GetArtistHistory(1))
.await;
let mut artist_history_table = StatefulTable {
sort_asc: true,
..StatefulTable::default()
};
if use_custom_sorting {
let cmp_fn = |a: &LidarrHistoryItem, b: &LidarrHistoryItem| {
a.source_title
.text
.to_lowercase()
.cmp(&b.source_title.text.to_lowercase())
};
expected_history_items.sort_by(cmp_fn);
let history_sort_option = SortOption {
name: "Source Title",
cmp_fn: Some(cmp_fn),
};
artist_history_table.sorting(vec![history_sort_option]);
}
app
.lock()
.await
.data
.lidarr_data
.artists
.set_items(vec![artist()]);
app.lock().await.data.lidarr_data.artist_history = Some(artist_history_table);
app.lock().await.server_tabs.set_index(2);
let mut network = test_network(&app);
let LidarrSerdeable::LidarrHistoryItems(history_items) = network
.handle_lidarr_event(LidarrEvent::GetArtistHistory(1))
.await
.unwrap()
else {
panic!("Expected LidarrHistoryItems")
};
async_server.assert_async().await;
let app = app.lock().await;
assert_some!(&app.data.lidarr_data.artist_history);
assert_eq!(
app.data.lidarr_data.artist_history.as_ref().unwrap().items,
expected_history_items
);
assert!(
app
.data
.lidarr_data
.artist_history
.as_ref()
.unwrap()
.sort_asc
);
assert_eq!(history_items, response);
}
#[tokio::test]
async fn test_handle_get_lidarr_artist_history_event_empty_artist_history_table() {
let history_json = json!([{
"id": 123,
"sourceTitle": "z album",
"albumId": 1007,
"artistId": 1007,
"quality": { "quality": { "name": "Lossless" } },
"date": "2023-01-01T00:00:00Z",
"eventType": "grabbed",
"data": {
"droppedPath": "/nfs/nzbget/completed/music/Something/cool.mp3",
"importedPath": "/nfs/music/Something/Album 1/Cool.mp3"
}
},
{
"id": 456,
"sourceTitle": "An Album",
"albumId": 2001,
"artistId": 2001,
"quality": { "quality": { "name": "Lossless" } },
"date": "2023-01-01T00:00:00Z",
"eventType": "grabbed",
"data": {
"droppedPath": "/nfs/nzbget/completed/music/Something/cool.mp3",
"importedPath": "/nfs/music/Something/Album 1/Cool.mp3"
}
}]);
let response: Vec<LidarrHistoryItem> = serde_json::from_value(history_json.clone()).unwrap();
let expected_history_items = vec![
LidarrHistoryItem {
id: 123,
artist_id: 1007,
album_id: 1007,
source_title: "z album".into(),
..lidarr_history_item()
},
LidarrHistoryItem {
id: 456,
artist_id: 2001,
album_id: 2001,
source_title: "An Album".into(),
..lidarr_history_item()
},
];
let (async_server, app, _server) = MockServarrApi::get()
.returns(history_json)
.query("artistId=1")
.build_for(LidarrEvent::GetArtistHistory(1))
.await;
app
.lock()
.await
.data
.lidarr_data
.artists
.set_items(vec![artist()]);
app.lock().await.server_tabs.set_index(2);
let mut network = test_network(&app);
let LidarrSerdeable::LidarrHistoryItems(history_items) = network
.handle_lidarr_event(LidarrEvent::GetArtistHistory(1))
.await
.unwrap()
else {
panic!("Expected LidarrHistoryItems")
};
async_server.assert_async().await;
let app = app.lock().await;
assert_some!(&app.data.lidarr_data.artist_history);
assert_eq!(
app.data.lidarr_data.artist_history.as_ref().unwrap().items,
expected_history_items
);
assert!(
!app
.data
.lidarr_data
.artist_history
.as_ref()
.unwrap()
.sort_asc
);
assert_eq!(history_items, response);
}
#[tokio::test]
async fn test_handle_get_lidarr_artist_history_event_no_op_when_user_is_selecting_sort_options() {
let history_json = json!([{
"id": 123,
"sourceTitle": "z album",
"albumId": 1007,
"artistId": 1007,
"quality": { "quality": { "name": "Lossless" } },
"date": "2023-01-01T00:00:00Z",
"eventType": "grabbed",
"data": {
"droppedPath": "/nfs/nzbget/completed/music/Something/cool.mp3",
"importedPath": "/nfs/music/Something/Album 1/Cool.mp3"
}
},
{
"id": 456,
"sourceTitle": "An Album",
"albumId": 2001,
"artistId": 2001,
"quality": { "quality": { "name": "Lossless" } },
"date": "2023-01-01T00:00:00Z",
"eventType": "grabbed",
"data": {
"droppedPath": "/nfs/nzbget/completed/music/Something/cool.mp3",
"importedPath": "/nfs/music/Something/Album 1/Cool.mp3"
}
}]);
let response: Vec<LidarrHistoryItem> = serde_json::from_value(history_json.clone()).unwrap();
let (async_server, app, _server) = MockServarrApi::get()
.returns(history_json)
.query("artistId=1")
.build_for(LidarrEvent::GetArtistHistory(1))
.await;
let cmp_fn = |a: &LidarrHistoryItem, b: &LidarrHistoryItem| {
a.source_title
.text
.to_lowercase()
.cmp(&b.source_title.text.to_lowercase())
};
let history_sort_option = SortOption {
name: "Source Title",
cmp_fn: Some(cmp_fn),
};
let mut artist_history_table = StatefulTable {
sort_asc: true,
..StatefulTable::default()
};
artist_history_table.sorting(vec![history_sort_option]);
app.lock().await.data.lidarr_data.artist_history = Some(artist_history_table);
app
.lock()
.await
.data
.lidarr_data
.artists
.set_items(vec![artist()]);
app
.lock()
.await
.push_navigation_stack(ActiveLidarrBlock::ArtistHistorySortPrompt.into());
app.lock().await.server_tabs.set_index(2);
let mut network = test_network(&app);
let LidarrSerdeable::LidarrHistoryItems(history_items) = network
.handle_lidarr_event(LidarrEvent::GetArtistHistory(1))
.await
.unwrap()
else {
panic!("Expected LidarrHistoryItems")
};
async_server.assert_async().await;
let app = app.lock().await;
assert_some!(&app.data.lidarr_data.artist_history);
assert!(
app
.data
.lidarr_data
.artist_history
.as_ref()
.unwrap()
.is_empty()
);
assert!(
app
.data
.lidarr_data
.artist_history
.as_ref()
.unwrap()
.sort_asc
);
assert_eq!(history_items, response);
}
#[tokio::test]
async fn test_handle_toggle_artist_monitoring_event() {
let artist_json = json!({
@@ -5,6 +5,7 @@ use serde_json::{Value, json};
use crate::models::Route;
use crate::models::lidarr_models::{
AddArtistBody, AddArtistSearchResult, Artist, DeleteParams, EditArtistParams, LidarrCommandBody,
LidarrHistoryItem,
};
use crate::models::servarr_data::lidarr::lidarr_data::ActiveLidarrBlock;
use crate::models::stateful_table::StatefulTable;
@@ -281,6 +282,41 @@ impl Network<'_, '_> {
.await
}
pub(in crate::network::lidarr_network) async fn get_lidarr_artist_history(
&mut self,
artist_id: i64,
) -> Result<Vec<LidarrHistoryItem>> {
info!("Fetching Lidarr artist history for artist with ID: {artist_id}");
let event = LidarrEvent::GetArtistHistory(artist_id);
let request_props = self
.request_props_from(
event,
RequestMethod::Get,
None::<()>,
None,
Some(format!("artistId={artist_id}")),
)
.await;
self
.handle_request::<(), Vec<LidarrHistoryItem>>(request_props, |mut history_vec, mut app| {
let is_sorting = matches!(
app.get_current_route(),
Route::Lidarr(ActiveLidarrBlock::ArtistHistorySortPrompt, _)
);
let artist_history = app.data.lidarr_data.artist_history.get_or_insert_default();
if !is_sorting {
history_vec.sort_by(|a, b| a.id.cmp(&b.id));
artist_history.set_items(history_vec);
artist_history.apply_sorting_toggle(false);
}
})
.await
}
pub(in crate::network::lidarr_network) async fn edit_artist(
&mut self,
mut edit_artist_params: EditArtistParams,
@@ -135,6 +135,7 @@ mod tests {
#[case(LidarrEvent::GetUpdates, "/update")]
#[case(LidarrEvent::HealthCheck, "/health")]
#[case(LidarrEvent::MarkHistoryItemAsFailed(0), "/history/failed")]
#[case(LidarrEvent::GetArtistHistory(0), "/history/artist")]
#[case(LidarrEvent::TestIndexer(0), "/indexer/test")]
#[case(LidarrEvent::TestAllIndexers, "/indexer/testall")]
fn test_resource(#[case] event: LidarrEvent, #[case] expected_uri: &str) {
+6
View File
@@ -40,6 +40,7 @@ pub enum LidarrEvent {
EditIndexer(EditIndexerParams),
GetAlbums(i64),
GetAlbumDetails(i64),
GetArtistHistory(i64),
GetAllIndexerSettings,
GetArtistDetails(i64),
GetDiskSpace,
@@ -89,6 +90,7 @@ impl NetworkResource for LidarrEvent {
| LidarrEvent::ToggleAlbumMonitoring(_)
| LidarrEvent::GetAlbumDetails(_)
| LidarrEvent::DeleteAlbum(_) => "/album",
LidarrEvent::GetArtistHistory(_) => "/history/artist",
LidarrEvent::GetLogs(_) => "/log",
LidarrEvent::GetDiskSpace => "/diskspace",
LidarrEvent::GetDownloads(_) | LidarrEvent::DeleteDownload(_) => "/queue",
@@ -192,6 +194,10 @@ impl Network<'_, '_> {
.get_lidarr_history(events)
.await
.map(LidarrSerdeable::from),
LidarrEvent::GetArtistHistory(artist_id) => self
.get_lidarr_artist_history(artist_id)
.await
.map(LidarrSerdeable::from),
LidarrEvent::GetLogs(events) => self
.get_lidarr_logs(events)
.await
@@ -5,7 +5,7 @@ mod tests {
use crate::models::stateful_table::SortOption;
use crate::network::network_tests::test_utils::{MockServarrApi, test_network};
use crate::network::sonarr_network::SonarrEvent;
use crate::network::sonarr_network::sonarr_network_test_utils::test_utils::history_item;
use crate::network::sonarr_network::sonarr_network_test_utils::test_utils::sonarr_history_item;
use pretty_assertions::assert_eq;
use rstest::rstest;
use serde_json::json;
@@ -45,13 +45,13 @@ mod tests {
id: 123,
episode_id: 1007,
source_title: "z episode".into(),
..history_item()
..sonarr_history_item()
},
SonarrHistoryItem {
id: 456,
episode_id: 2001,
source_title: "A Episode".into(),
..history_item()
..sonarr_history_item()
},
];
let (mock, app, _server) = MockServarrApi::get()
@@ -12,7 +12,7 @@ mod tests {
use crate::network::sonarr_network::SonarrEvent;
use crate::network::sonarr_network::library::episodes::get_episode_status;
use crate::network::sonarr_network::sonarr_network_test_utils::test_utils::{
EPISODE_JSON, episode, episode_file, history_item, torrent_release,
EPISODE_JSON, episode, episode_file, sonarr_history_item, torrent_release,
};
use indoc::formatdoc;
use mockito::Matcher;
@@ -522,13 +522,13 @@ mod tests {
id: 123,
episode_id: 1007,
source_title: "z episode".into(),
..history_item()
..sonarr_history_item()
},
SonarrHistoryItem {
id: 456,
episode_id: 2001,
source_title: "A Episode".into(),
..history_item()
..sonarr_history_item()
},
];
let (async_server, app_arc, _server) = MockServarrApi::get()
@@ -649,13 +649,13 @@ mod tests {
id: 123,
episode_id: 1007,
source_title: "z episode".into(),
..history_item()
..sonarr_history_item()
},
SonarrHistoryItem {
id: 456,
episode_id: 2001,
source_title: "A Episode".into(),
..history_item()
..sonarr_history_item()
},
];
let (async_server, app_arc, _server) = MockServarrApi::get()
@@ -754,13 +754,13 @@ mod tests {
id: 123,
episode_id: 1007,
source_title: "z episode".into(),
..history_item()
..sonarr_history_item()
},
SonarrHistoryItem {
id: 456,
episode_id: 2001,
source_title: "A Episode".into(),
..history_item()
..sonarr_history_item()
},
];
let (async_server, app_arc, _server) = MockServarrApi::get()
@@ -6,7 +6,7 @@ mod tests {
use crate::network::network_tests::test_utils::{MockServarrApi, test_network};
use crate::network::sonarr_network::SonarrEvent;
use crate::network::sonarr_network::sonarr_network_test_utils::test_utils::{
SERIES_JSON, history_item, season, series, torrent_release,
SERIES_JSON, season, series, sonarr_history_item, torrent_release,
};
use mockito::Matcher;
use pretty_assertions::assert_eq;
@@ -278,13 +278,13 @@ mod tests {
id: 123,
episode_id: 1007,
source_title: "z episode".into(),
..history_item()
..sonarr_history_item()
},
SonarrHistoryItem {
id: 456,
episode_id: 2001,
source_title: "A Episode".into(),
..history_item()
..sonarr_history_item()
},
];
let (mock, app, _server) = MockServarrApi::get()
@@ -390,13 +390,13 @@ mod tests {
id: 123,
episode_id: 1007,
source_title: "z episode".into(),
..history_item()
..sonarr_history_item()
},
SonarrHistoryItem {
id: 456,
episode_id: 2001,
source_title: "A Episode".into(),
..history_item()
..sonarr_history_item()
},
];
let (mock, app, _server) = MockServarrApi::get()
@@ -10,7 +10,7 @@ mod tests {
use crate::network::network_tests::test_utils::{MockServarrApi, test_network};
use crate::network::sonarr_network::SonarrEvent;
use crate::network::sonarr_network::sonarr_network_test_utils::test_utils::{
SERIES_JSON, add_series_search_result, history_item, season, series,
SERIES_JSON, add_series_search_result, season, series, sonarr_history_item,
};
use bimap::BiMap;
use mockito::Matcher;
@@ -457,13 +457,13 @@ mod tests {
id: 123,
episode_id: 1007,
source_title: "z episode".into(),
..history_item()
..sonarr_history_item()
},
SonarrHistoryItem {
id: 456,
episode_id: 2001,
source_title: "A Episode".into(),
..history_item()
..sonarr_history_item()
},
];
let (async_server, app, _server) = MockServarrApi::get()
@@ -570,13 +570,13 @@ mod tests {
id: 123,
episode_id: 1007,
source_title: "z episode".into(),
..history_item()
..sonarr_history_item()
},
SonarrHistoryItem {
id: 456,
episode_id: 2001,
source_title: "A Episode".into(),
..history_item()
..sonarr_history_item()
},
];
let (async_server, app, _server) = MockServarrApi::get()
@@ -203,7 +203,7 @@ pub mod test_utils {
}
}
pub fn history_item() -> SonarrHistoryItem {
pub fn sonarr_history_item() -> SonarrHistoryItem {
SonarrHistoryItem {
id: 1,
source_title: "Test source".into(),