feat: Blocklist support in Lidarr in both the CLI and TUI

This commit is contained in:
2026-01-19 16:13:11 -07:00
parent eff1a901eb
commit 89f5ff6bc7
48 changed files with 2211 additions and 66 deletions
@@ -0,0 +1,353 @@
#[cfg(test)]
mod tests {
use crate::models::lidarr_models::{Artist, BlocklistItem, BlocklistResponse, LidarrSerdeable};
use crate::models::servarr_data::lidarr::lidarr_data::ActiveLidarrBlock;
use crate::models::stateful_table::SortOption;
use crate::network::lidarr_network::LidarrEvent;
use crate::network::lidarr_network::lidarr_network_test_utils::test_utils::{
artist, blocklist_item,
};
use crate::network::network_tests::test_utils::{MockServarrApi, test_network};
use pretty_assertions::assert_eq;
use rstest::rstest;
use serde_json::{Number, json};
#[tokio::test]
async fn test_handle_clear_lidarr_blocklist_event() {
let blocklist_items = vec![
BlocklistItem {
id: 1,
..blocklist_item()
},
BlocklistItem {
id: 2,
..blocklist_item()
},
BlocklistItem {
id: 3,
..blocklist_item()
},
];
let expected_request_json = json!({ "ids": [1, 2, 3]});
let (mock, app, _server) = MockServarrApi::delete()
.with_request_body(expected_request_json)
.build_for(LidarrEvent::ClearBlocklist)
.await;
app
.lock()
.await
.data
.lidarr_data
.blocklist
.set_items(blocklist_items);
app.lock().await.server_tabs.set_index(2);
let mut network = test_network(&app);
assert!(
network
.handle_lidarr_event(LidarrEvent::ClearBlocklist)
.await
.is_ok()
);
mock.assert_async().await;
}
#[tokio::test]
async fn test_handle_delete_lidarr_blocklist_item_event() {
let (mock, app, _server) = MockServarrApi::delete()
.path("/1")
.build_for(LidarrEvent::DeleteBlocklistItem(1))
.await;
app
.lock()
.await
.data
.lidarr_data
.blocklist
.set_items(vec![blocklist_item()]);
app.lock().await.server_tabs.set_index(2);
let mut network = test_network(&app);
assert!(
network
.handle_lidarr_event(LidarrEvent::DeleteBlocklistItem(1))
.await
.is_ok()
);
mock.assert_async().await;
}
#[rstest]
#[tokio::test]
async fn test_handle_get_lidarr_blocklist_event(#[values(true, false)] use_custom_sorting: bool) {
let blocklist_json = json!({"records": [{
"artistId": 1007,
"albumIds": [42020],
"sourceTitle": "z artist",
"quality": { "quality": { "name": "Lossless" }},
"date": "2023-05-20T21:29:16Z",
"protocol": "usenet",
"indexer": "NZBgeek (Prowlarr)",
"message": "test message",
"id": 123,
"artist": {
"id": 1,
"artistName": "Alex",
"foreignArtistId": "test-foreign-id",
"status": "continuing",
"overview": "some interesting description of the artist",
"artistType": "Person",
"disambiguation": "American pianist",
"path": "/nfs/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
}
}
},
{
"artistId": 2001,
"artistTitle": "Test Artist",
"albumIds": [42018],
"sourceTitle": "A Artist",
"quality": { "quality": { "name": "Lossless" }},
"date": "2023-05-20T21:29:16Z",
"protocol": "usenet",
"indexer": "NZBgeek (Prowlarr)",
"message": "test message",
"id": 456,
"artist": {
"id": 1,
"artistName": "Alex",
"foreignArtistId": "test-foreign-id",
"status": "continuing",
"overview": "some interesting description of the artist",
"artistType": "Person",
"disambiguation": "American pianist",
"path": "/nfs/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: BlocklistResponse = serde_json::from_value(blocklist_json.clone()).unwrap();
let mut expected_blocklist = vec![
BlocklistItem {
id: 123,
artist_id: 1007,
source_title: "z artist".into(),
album_ids: Some(vec![Number::from(42020)]),
..blocklist_item()
},
BlocklistItem {
id: 456,
artist_id: 2001,
source_title: "A Artist".into(),
album_ids: Some(vec![Number::from(42018)]),
..blocklist_item()
},
];
let (mock, app, _server) = MockServarrApi::get()
.returns(blocklist_json)
.build_for(LidarrEvent::GetBlocklist)
.await;
app
.lock()
.await
.data
.lidarr_data
.artists
.set_items(vec![Artist {
id: 1007,
artist_name: "Z Artist".into(),
..artist()
}]);
app.lock().await.data.lidarr_data.blocklist.sort_asc = true;
if use_custom_sorting {
let cmp_fn = |a: &BlocklistItem, b: &BlocklistItem| {
a.source_title
.to_lowercase()
.cmp(&b.source_title.to_lowercase())
};
expected_blocklist.sort_by(cmp_fn);
let blocklist_sort_option = SortOption {
name: "Source Title",
cmp_fn: Some(cmp_fn),
};
app
.lock()
.await
.data
.lidarr_data
.blocklist
.sorting(vec![blocklist_sort_option]);
}
app.lock().await.server_tabs.set_index(2);
let mut network = test_network(&app);
let LidarrSerdeable::BlocklistResponse(blocklist) = network
.handle_lidarr_event(LidarrEvent::GetBlocklist)
.await
.unwrap()
else {
panic!("Expected BlocklistResponse")
};
mock.assert_async().await;
assert_eq!(
app.lock().await.data.lidarr_data.blocklist.items,
expected_blocklist
);
assert!(app.lock().await.data.lidarr_data.blocklist.sort_asc);
assert_eq!(blocklist, response);
}
#[tokio::test]
async fn test_handle_get_lidarr_blocklist_event_no_op_when_user_is_selecting_sort_options() {
let blocklist_json = json!({"records": [{
"artistId": 1007,
"albumIds": [42020],
"sourceTitle": "z artist",
"quality": { "quality": { "name": "Lossless" }},
"date": "2023-05-20T21:29:16Z",
"protocol": "usenet",
"indexer": "NZBgeek (Prowlarr)",
"message": "test message",
"id": 123,
"artist": {
"id": 1,
"artistName": "Alex",
"foreignArtistId": "test-foreign-id",
"status": "continuing",
"overview": "some interesting description of the artist",
"artistType": "Person",
"disambiguation": "American pianist",
"path": "/nfs/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
}
}
},
{
"artistId": 2001,
"albumIds": [42018],
"sourceTitle": "A Artist",
"quality": { "quality": { "name": "Lossless" }},
"date": "2023-05-20T21:29:16Z",
"protocol": "usenet",
"indexer": "NZBgeek (Prowlarr)",
"message": "test message",
"id": 456,
"artist": {
"id": 1,
"artistName": "Alex",
"foreignArtistId": "test-foreign-id",
"status": "continuing",
"overview": "some interesting description of the artist",
"artistType": "Person",
"disambiguation": "American pianist",
"path": "/nfs/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: BlocklistResponse = serde_json::from_value(blocklist_json.clone()).unwrap();
let (mock, app, _server) = MockServarrApi::get()
.returns(blocklist_json)
.build_for(LidarrEvent::GetBlocklist)
.await;
app.lock().await.data.lidarr_data.blocklist.sort_asc = true;
app
.lock()
.await
.push_navigation_stack(ActiveLidarrBlock::BlocklistSortPrompt.into());
let cmp_fn = |a: &BlocklistItem, b: &BlocklistItem| {
a.source_title
.to_lowercase()
.cmp(&b.source_title.to_lowercase())
};
let blocklist_sort_option = SortOption {
name: "Source Title",
cmp_fn: Some(cmp_fn),
};
app
.lock()
.await
.data
.lidarr_data
.blocklist
.sorting(vec![blocklist_sort_option]);
app.lock().await.server_tabs.set_index(2);
let mut network = test_network(&app);
let LidarrSerdeable::BlocklistResponse(blocklist) = network
.handle_lidarr_event(LidarrEvent::GetBlocklist)
.await
.unwrap()
else {
panic!("Expected BlocklistResponse")
};
mock.assert_async().await;
assert_is_empty!(app.lock().await.data.lidarr_data.blocklist);
assert!(app.lock().await.data.lidarr_data.blocklist.sort_asc);
assert_eq!(blocklist, response);
}
}
@@ -0,0 +1,92 @@
use crate::models::Route;
use crate::models::lidarr_models::{BlocklistItem, BlocklistResponse};
use crate::models::servarr_data::lidarr::lidarr_data::ActiveLidarrBlock;
use crate::network::lidarr_network::LidarrEvent;
use crate::network::{Network, RequestMethod};
use anyhow::Result;
use log::info;
use serde_json::{Value, json};
#[cfg(test)]
#[path = "lidarr_blocklist_network_tests.rs"]
mod lidarr_blocklist_network_tests;
impl Network<'_, '_> {
pub(in crate::network::lidarr_network) async fn clear_lidarr_blocklist(&mut self) -> Result<()> {
info!("Clearing Lidarr blocklist");
let event = LidarrEvent::ClearBlocklist;
let ids = self
.app
.lock()
.await
.data
.lidarr_data
.blocklist
.items
.iter()
.map(|item| item.id)
.collect::<Vec<i64>>();
let request_props = self
.request_props_from(
event,
RequestMethod::Delete,
Some(json!({"ids": ids})),
None,
None,
)
.await;
self
.handle_request::<Value, ()>(request_props, |_, _| ())
.await
}
pub(in crate::network::lidarr_network) async fn delete_lidarr_blocklist_item(
&mut self,
blocklist_item_id: i64,
) -> Result<()> {
let event = LidarrEvent::DeleteBlocklistItem(blocklist_item_id);
info!("Deleting Lidarr blocklist item for item with id: {blocklist_item_id}");
let request_props = self
.request_props_from(
event,
RequestMethod::Delete,
None::<()>,
Some(format!("/{blocklist_item_id}")),
None,
)
.await;
self
.handle_request::<(), ()>(request_props, |_, _| ())
.await
}
pub(in crate::network::lidarr_network) async fn get_lidarr_blocklist(
&mut self,
) -> Result<BlocklistResponse> {
info!("Fetching Lidarr blocklist");
let event = LidarrEvent::GetBlocklist;
let request_props = self
.request_props_from(event, RequestMethod::Get, None::<()>, None, None)
.await;
self
.handle_request::<(), BlocklistResponse>(request_props, |blocklist_resp, mut app| {
if !matches!(
app.get_current_route(),
Route::Lidarr(ActiveLidarrBlock::BlocklistSortPrompt, _)
) {
let mut blocklist_vec: Vec<BlocklistItem> = blocklist_resp.records;
blocklist_vec.sort_by(|a, b| a.id.cmp(&b.id));
app.data.lidarr_data.blocklist.set_items(blocklist_vec);
app.data.lidarr_data.blocklist.apply_sorting_toggle(false);
}
})
.await
}
}
@@ -3,10 +3,10 @@
pub mod test_utils {
use crate::models::lidarr_models::{
AddArtistSearchResult, Album, AlbumStatistics, Artist, ArtistStatistics, ArtistStatus,
AudioTags, DownloadRecord, DownloadStatus, DownloadsResponse, EditArtistParams,
LidarrHistoryData, LidarrHistoryEventType, LidarrHistoryItem, LidarrHistoryWrapper,
LidarrRelease, LidarrTask, LidarrTaskName, MediaInfo, Member, MetadataProfile,
NewItemMonitorType, Ratings, SystemStatus, Track, TrackFile,
AudioTags, BlocklistItem, BlocklistResponse, DownloadRecord, DownloadStatus, DownloadsResponse,
EditArtistParams, LidarrHistoryData, LidarrHistoryEventType, LidarrHistoryItem,
LidarrHistoryWrapper, LidarrRelease, LidarrTask, LidarrTaskName, MediaInfo, Member,
MetadataProfile, NewItemMonitorType, Ratings, SystemStatus, Track, TrackFile,
};
use crate::models::servarr_models::IndexerSettings;
use crate::models::servarr_models::{
@@ -477,4 +477,25 @@ pub mod test_utils {
track_file: Some(track_file()),
}
}
pub fn blocklist_item() -> BlocklistItem {
BlocklistItem {
id: 1,
artist_id: 1,
album_ids: Some(vec![1.into()]),
source_title: "Alex - Something".to_string(),
quality: quality_wrapper(),
date: DateTime::from(DateTime::parse_from_rfc3339("2023-05-20T21:29:16Z").unwrap()),
protocol: "usenet".to_string(),
indexer: "NZBgeek (Prowlarr)".to_string(),
message: "test message".to_string(),
artist: artist(),
}
}
pub fn blocklist_response() -> BlocklistResponse {
BlocklistResponse {
records: vec![blocklist_item()],
}
}
}
@@ -159,6 +159,9 @@ mod tests {
}
#[rstest]
#[case(LidarrEvent::ClearBlocklist, "/blocklist/bulk")]
#[case(LidarrEvent::DeleteBlocklistItem(0), "/blocklist")]
#[case(LidarrEvent::GetBlocklist, "/blocklist?page=1&pageSize=10000")]
#[case(LidarrEvent::GetDiskSpace, "/diskspace")]
#[case(LidarrEvent::GetMetadataProfiles, "/metadataprofile")]
#[case(LidarrEvent::GetQualityProfiles, "/qualityprofile")]
+16
View File
@@ -9,6 +9,7 @@ use crate::models::lidarr_models::{
use crate::models::servarr_models::{EditIndexerParams, IndexerSettings, QualityProfile, Tag};
use crate::network::{Network, RequestMethod};
mod blocklist;
mod downloads;
mod history;
mod indexers;
@@ -29,8 +30,10 @@ pub enum LidarrEvent {
AddArtist(AddArtistBody),
AddRootFolder(AddLidarrRootFolderBody),
AddTag(String),
ClearBlocklist,
DeleteAlbum(DeleteParams),
DeleteArtist(DeleteParams),
DeleteBlocklistItem(i64),
DeleteDownload(i64),
DeleteIndexer(i64),
DeleteRootFolder(i64),
@@ -47,6 +50,7 @@ pub enum LidarrEvent {
GetArtistHistory(i64),
GetAllIndexerSettings,
GetArtistDetails(i64),
GetBlocklist,
GetDiscographyReleases(i64),
GetDiskSpace,
GetDownloads(u64),
@@ -87,7 +91,9 @@ impl NetworkResource for LidarrEvent {
fn resource(&self) -> &'static str {
match &self {
LidarrEvent::AddTag(_) | LidarrEvent::DeleteTag(_) | LidarrEvent::GetTags => "/tag",
LidarrEvent::ClearBlocklist => "/blocklist/bulk",
LidarrEvent::DeleteTrackFile(_) | LidarrEvent::GetTrackFiles(_) => "/trackfile",
LidarrEvent::DeleteBlocklistItem(_) => "/blocklist",
LidarrEvent::GetAllIndexerSettings | LidarrEvent::EditAllIndexerSettings(_) => {
"/config/indexer"
}
@@ -104,6 +110,7 @@ impl NetworkResource for LidarrEvent {
LidarrEvent::GetArtistHistory(_)
| LidarrEvent::GetAlbumHistory(_, _)
| LidarrEvent::GetTrackHistory(_, _, _) => "/history/artist",
LidarrEvent::GetBlocklist => "/blocklist?page=1&pageSize=10000",
LidarrEvent::GetLogs(_) => "/log",
LidarrEvent::GetDiskSpace => "/diskspace",
LidarrEvent::GetDownloads(_) | LidarrEvent::DeleteDownload(_) => "/queue",
@@ -157,12 +164,20 @@ impl Network<'_, '_> {
.add_lidarr_root_folder(path)
.await
.map(LidarrSerdeable::from),
LidarrEvent::ClearBlocklist => self
.clear_lidarr_blocklist()
.await
.map(LidarrSerdeable::from),
LidarrEvent::DeleteAlbum(params) => {
self.delete_album(params).await.map(LidarrSerdeable::from)
}
LidarrEvent::DeleteArtist(params) => {
self.delete_artist(params).await.map(LidarrSerdeable::from)
}
LidarrEvent::DeleteBlocklistItem(blocklist_item_id) => self
.delete_lidarr_blocklist_item(blocklist_item_id)
.await
.map(LidarrSerdeable::from),
LidarrEvent::DeleteDownload(download_id) => self
.delete_lidarr_download(download_id)
.await
@@ -218,6 +233,7 @@ impl Network<'_, '_> {
.get_album_releases(artist_id, album_id)
.await
.map(LidarrSerdeable::from),
LidarrEvent::GetBlocklist => self.get_lidarr_blocklist().await.map(LidarrSerdeable::from),
LidarrEvent::GetDiscographyReleases(artist_id) => self
.get_artist_discography_releases(artist_id)
.await