feat: Full Lidarr system support for both the CLI and TUI

This commit is contained in:
2026-01-14 14:50:33 -07:00
parent c74d5936d2
commit 8b9467bd39
63 changed files with 4824 additions and 74 deletions
@@ -1,19 +1,20 @@
#[cfg(test)]
#[allow(dead_code)]
pub mod test_utils {
use crate::models::HorizontallyScrollableText;
use crate::models::lidarr_models::{
AddArtistSearchResult, Album, AlbumStatistics, Artist, ArtistStatistics, ArtistStatus,
DownloadRecord, DownloadStatus, DownloadsResponse, EditArtistParams, LidarrHistoryData,
LidarrHistoryEventType, LidarrHistoryItem, LidarrHistoryWrapper, Member, MetadataProfile,
NewItemMonitorType, Ratings, SystemStatus,
LidarrHistoryEventType, LidarrHistoryItem, LidarrHistoryWrapper, LidarrTask, LidarrTaskName,
Member, MetadataProfile, NewItemMonitorType, Ratings, SystemStatus,
};
use crate::models::servarr_models::IndexerSettings;
use crate::models::servarr_models::{
Indexer, IndexerField, Quality, QualityProfile, QualityWrapper, RootFolder, Tag,
};
use crate::models::{HorizontallyScrollableText, ScrollableText};
use bimap::BiMap;
use chrono::DateTime;
use indoc::formatdoc;
use serde_json::{Number, json};
pub const ADD_ARTIST_SEARCH_RESULT_JSON: &str = r#"{
@@ -333,4 +334,47 @@ pub mod test_utils {
rss_sync_interval: 60,
}
}
pub fn task() -> LidarrTask {
LidarrTask {
name: "Backup".to_owned(),
task_name: LidarrTaskName::Backup,
interval: 60,
last_execution: DateTime::from(DateTime::parse_from_rfc3339("2023-05-20T21:29:16Z").unwrap()),
next_execution: DateTime::from(DateTime::parse_from_rfc3339("2023-05-20T22:29:16Z").unwrap()),
}
}
pub fn log_line() -> &'static str {
"2025-12-16 16:40:59 UTC|INFO|ImportListSyncService|No list items to process"
}
pub fn updates() -> ScrollableText {
let line_break = "-".repeat(200);
ScrollableText::with_string(formatdoc!(
"
The latest version of Lidarr is already installed
4.3.2.1 - 2023-04-15 02:02:53 UTC (Currently Installed)
{line_break}
New:
* Cool new thing
Fixed:
* Some bugs killed
3.2.1.0 - 2023-04-15 02:02:53 UTC (Previously Installed)
{line_break}
New:
* Cool new thing (old)
* Other cool new thing (old)
2.1.0 - 2023-04-15 02:02:53 UTC
{line_break}
Fixed:
* Killed bug 1
* Fixed bug 2"
))
}
}
@@ -90,7 +90,9 @@ mod tests {
LidarrEvent::UpdateAllArtists,
LidarrEvent::TriggerAutomaticArtistSearch(0),
LidarrEvent::UpdateAndScanArtist(0),
LidarrEvent::UpdateDownloads
LidarrEvent::UpdateDownloads,
LidarrEvent::GetQueuedEvents,
LidarrEvent::StartTask(Default::default())
)]
event: LidarrEvent,
) {
@@ -128,6 +130,9 @@ mod tests {
#[case(LidarrEvent::GetQualityProfiles, "/qualityprofile")]
#[case(LidarrEvent::GetStatus, "/system/status")]
#[case(LidarrEvent::GetTags, "/tag")]
#[case(LidarrEvent::GetLogs(500), "/log")]
#[case(LidarrEvent::GetTasks, "/system/task")]
#[case(LidarrEvent::GetUpdates, "/update")]
#[case(LidarrEvent::HealthCheck, "/health")]
#[case(LidarrEvent::MarkHistoryItemAsFailed(0), "/history/failed")]
#[case(LidarrEvent::TestIndexer(0), "/indexer/test")]
+26 -2
View File
@@ -4,7 +4,7 @@ use log::info;
use super::{NetworkEvent, NetworkResource};
use crate::models::lidarr_models::{
AddArtistBody, AddLidarrRootFolderBody, DeleteParams, EditArtistParams, LidarrSerdeable,
MetadataProfile,
LidarrTaskName, MetadataProfile,
};
use crate::models::servarr_models::{EditIndexerParams, IndexerSettings, QualityProfile, Tag};
use crate::network::{Network, RequestMethod};
@@ -47,16 +47,21 @@ pub enum LidarrEvent {
GetHistory(u64),
GetHostConfig,
GetIndexers,
GetLogs(u64),
MarkHistoryItemAsFailed(i64),
GetMetadataProfiles,
GetQualityProfiles,
GetQueuedEvents,
GetRootFolders,
GetSecurityConfig,
GetStatus,
GetUpdates,
GetTags,
GetTasks,
HealthCheck,
ListArtists,
SearchNewArtist(String),
StartTask(LidarrTaskName),
TestIndexer(i64),
TestAllIndexers,
ToggleAlbumMonitoring(i64),
@@ -84,6 +89,7 @@ impl NetworkResource for LidarrEvent {
| LidarrEvent::ToggleAlbumMonitoring(_)
| LidarrEvent::GetAlbumDetails(_)
| LidarrEvent::DeleteAlbum(_) => "/album",
LidarrEvent::GetLogs(_) => "/log",
LidarrEvent::GetDiskSpace => "/diskspace",
LidarrEvent::GetDownloads(_) | LidarrEvent::DeleteDownload(_) => "/queue",
LidarrEvent::GetHistory(_) => "/history",
@@ -95,7 +101,9 @@ impl NetworkResource for LidarrEvent {
LidarrEvent::TriggerAutomaticArtistSearch(_)
| LidarrEvent::UpdateAllArtists
| LidarrEvent::UpdateAndScanArtist(_)
| LidarrEvent::UpdateDownloads => "/command",
| LidarrEvent::UpdateDownloads
| LidarrEvent::GetQueuedEvents
| LidarrEvent::StartTask(_) => "/command",
LidarrEvent::GetMetadataProfiles => "/metadataprofile",
LidarrEvent::GetQualityProfiles => "/qualityprofile",
LidarrEvent::GetRootFolders
@@ -104,6 +112,8 @@ impl NetworkResource for LidarrEvent {
LidarrEvent::TestIndexer(_) => "/indexer/test",
LidarrEvent::TestAllIndexers => "/indexer/testall",
LidarrEvent::GetStatus => "/system/status",
LidarrEvent::GetTasks => "/system/task",
LidarrEvent::GetUpdates => "/update",
LidarrEvent::HealthCheck => "/health",
LidarrEvent::SearchNewArtist(_) => "/artist/lookup",
}
@@ -182,6 +192,10 @@ impl Network<'_, '_> {
.get_lidarr_history(events)
.await
.map(LidarrSerdeable::from),
LidarrEvent::GetLogs(events) => self
.get_lidarr_logs(events)
.await
.map(LidarrSerdeable::from),
LidarrEvent::MarkHistoryItemAsFailed(history_item_id) => self
.mark_lidarr_history_item_as_failed(history_item_id)
.await
@@ -198,6 +212,10 @@ impl Network<'_, '_> {
.get_lidarr_quality_profiles()
.await
.map(LidarrSerdeable::from),
LidarrEvent::GetQueuedEvents => self
.get_queued_lidarr_events()
.await
.map(LidarrSerdeable::from),
LidarrEvent::GetRootFolders => self
.get_lidarr_root_folders()
.await
@@ -208,6 +226,8 @@ impl Network<'_, '_> {
.map(LidarrSerdeable::from),
LidarrEvent::GetStatus => self.get_lidarr_status().await.map(LidarrSerdeable::from),
LidarrEvent::GetTags => self.get_lidarr_tags().await.map(LidarrSerdeable::from),
LidarrEvent::GetTasks => self.get_lidarr_tasks().await.map(LidarrSerdeable::from),
LidarrEvent::GetUpdates => self.get_lidarr_updates().await.map(LidarrSerdeable::from),
LidarrEvent::HealthCheck => self
.get_lidarr_healthcheck()
.await
@@ -216,6 +236,10 @@ impl Network<'_, '_> {
LidarrEvent::SearchNewArtist(query) => {
self.search_artist(query).await.map(LidarrSerdeable::from)
}
LidarrEvent::StartTask(task_name) => self
.start_lidarr_task(task_name)
.await
.map(LidarrSerdeable::from),
LidarrEvent::ToggleAlbumMonitoring(album_id) => self
.toggle_album_monitoring(album_id)
.await
@@ -1,9 +1,14 @@
#[cfg(test)]
mod tests {
use crate::models::lidarr_models::{LidarrSerdeable, SystemStatus};
use crate::models::servarr_models::{DiskSpace, HostConfig, SecurityConfig};
use crate::models::HorizontallyScrollableText;
use crate::models::lidarr_models::{LidarrSerdeable, LidarrTask, LidarrTaskName, SystemStatus};
use crate::models::servarr_models::{
DiskSpace, HostConfig, LogResponse, QueueEvent, SecurityConfig, Update,
};
use crate::network::lidarr_network::LidarrEvent;
use crate::network::lidarr_network::lidarr_network_test_utils::test_utils::updates;
use crate::network::network_tests::test_utils::{MockServarrApi, test_network};
use chrono::DateTime;
use pretty_assertions::assert_eq;
use serde_json::json;
@@ -104,6 +109,117 @@ mod tests {
assert_eq!(security_config, response);
}
#[tokio::test]
async fn test_handle_get_lidarr_logs_event() {
let expected_logs = vec![
HorizontallyScrollableText::from(
"2023-05-20 21:29:16 UTC|FATAL|LidarrError|Some.Big.Bad.Exception|test exception",
),
HorizontallyScrollableText::from("2023-05-20 21:29:16 UTC|INFO|TestLogger|test message"),
];
let logs_response_json = json!({
"page": 1,
"pageSize": 500,
"sortKey": "time",
"sortDirection": "descending",
"totalRecords": 2,
"records": [
{
"time": "2023-05-20T21:29:16Z",
"level": "info",
"logger": "TestLogger",
"message": "test message",
"id": 1
},
{
"time": "2023-05-20T21:29:16Z",
"level": "fatal",
"logger": "LidarrError",
"exception": "test exception",
"exceptionType": "Some.Big.Bad.Exception",
"id": 2
}
]
});
let response: LogResponse = serde_json::from_value(logs_response_json.clone()).unwrap();
let (mock, app, _server) = MockServarrApi::get()
.returns(logs_response_json)
.query("pageSize=500&sortDirection=descending&sortKey=time")
.build_for(LidarrEvent::GetLogs(500))
.await;
app.lock().await.server_tabs.set_index(2);
let mut network = test_network(&app);
let LidarrSerdeable::LogResponse(logs) = network
.handle_lidarr_event(LidarrEvent::GetLogs(500))
.await
.unwrap()
else {
panic!("Expected LogResponse")
};
mock.assert_async().await;
assert_eq!(app.lock().await.data.lidarr_data.logs.items, expected_logs);
assert!(
app
.lock()
.await
.data
.lidarr_data
.logs
.current_selection()
.text
.contains("INFO")
);
assert_eq!(logs, response);
}
#[tokio::test]
async fn test_handle_get_queued_lidarr_events_event() {
let queued_events_json = json!([{
"name": "RefreshMonitoredDownloads",
"commandName": "Refresh Monitored Downloads",
"status": "completed",
"queued": "2023-05-20T21:29:16Z",
"started": "2023-05-20T21:29:16Z",
"ended": "2023-05-20T21:29:16Z",
"duration": "00:00:00.5111547",
"trigger": "scheduled",
}]);
let response: Vec<QueueEvent> = serde_json::from_value(queued_events_json.clone()).unwrap();
let timestamp = DateTime::from(DateTime::parse_from_rfc3339("2023-05-20T21:29:16Z").unwrap());
let expected_event = QueueEvent {
name: "RefreshMonitoredDownloads".to_owned(),
command_name: "Refresh Monitored Downloads".to_owned(),
status: "completed".to_owned(),
queued: timestamp,
started: Some(timestamp),
ended: Some(timestamp),
duration: Some("00:00:00.5111547".to_owned()),
trigger: "scheduled".to_owned(),
};
let (mock, app, _server) = MockServarrApi::get()
.returns(queued_events_json)
.build_for(LidarrEvent::GetQueuedEvents)
.await;
app.lock().await.server_tabs.set_index(2);
let mut network = test_network(&app);
let LidarrSerdeable::QueueEvents(events) = network
.handle_lidarr_event(LidarrEvent::GetQueuedEvents)
.await
.unwrap()
else {
panic!("Expected QueueEvents")
};
mock.assert_async().await;
assert_eq!(
app.lock().await.data.lidarr_data.queued_events.items,
vec![expected_event]
);
assert_eq!(events, response);
}
#[tokio::test]
async fn test_handle_get_status_event() {
let status_json = json!({
@@ -129,4 +245,171 @@ mod tests {
assert_eq!(status, response);
assert_eq!(app.lock().await.data.lidarr_data.version, "1.0.0");
}
#[tokio::test]
async fn test_handle_get_lidarr_tasks_event() {
let tasks_json = json!([{
"name": "Application Update Check",
"taskName": "ApplicationUpdateCheck",
"interval": 360,
"lastExecution": "2023-05-20T21:29:16Z",
"nextExecution": "2023-05-20T21:29:16Z",
},
{
"name": "Backup",
"taskName": "Backup",
"interval": 10080,
"lastExecution": "2023-05-20T21:29:16Z",
"nextExecution": "2023-05-20T21:29:16Z",
}]);
let response: Vec<LidarrTask> = serde_json::from_value(tasks_json.clone()).unwrap();
let timestamp = DateTime::from(DateTime::parse_from_rfc3339("2023-05-20T21:29:16Z").unwrap());
let expected_tasks = vec![
LidarrTask {
name: "Application Update Check".to_owned(),
task_name: LidarrTaskName::ApplicationUpdateCheck,
interval: 360,
last_execution: timestamp,
next_execution: timestamp,
},
LidarrTask {
name: "Backup".to_owned(),
task_name: LidarrTaskName::Backup,
interval: 10080,
last_execution: timestamp,
next_execution: timestamp,
},
];
let (mock, app, _server) = MockServarrApi::get()
.returns(tasks_json)
.build_for(LidarrEvent::GetTasks)
.await;
app.lock().await.server_tabs.set_index(2);
let mut network = test_network(&app);
let LidarrSerdeable::Tasks(tasks) = network
.handle_lidarr_event(LidarrEvent::GetTasks)
.await
.unwrap()
else {
panic!("Expected Tasks")
};
mock.assert_async().await;
assert_eq!(
app.lock().await.data.lidarr_data.tasks.items,
expected_tasks
);
assert_eq!(tasks, response);
}
#[tokio::test]
async fn test_handle_get_lidarr_updates_event() {
let updates_json = json!([{
"version": "4.3.2.1",
"releaseDate": "2023-04-15T02:02:53Z",
"installed": true,
"installedOn": "2023-04-15T02:02:53Z",
"latest": true,
"changes": {
"new": [
"Cool new thing"
],
"fixed": [
"Some bugs killed"
]
},
},
{
"version": "3.2.1.0",
"releaseDate": "2023-04-15T02:02:53Z",
"installed": false,
"installedOn": "2023-04-15T02:02:53Z",
"latest": false,
"changes": {
"new": [
"Cool new thing (old)",
"Other cool new thing (old)"
],
},
},
{
"version": "2.1.0",
"releaseDate": "2023-04-15T02:02:53Z",
"installed": false,
"latest": false,
"changes": {
"fixed": [
"Killed bug 1",
"Fixed bug 2"
]
},
}]);
let response: Vec<Update> = serde_json::from_value(updates_json.clone()).unwrap();
let expected_text = updates();
let (mock, app, _server) = MockServarrApi::get()
.returns(updates_json)
.build_for(LidarrEvent::GetUpdates)
.await;
app.lock().await.server_tabs.set_index(2);
let mut network = test_network(&app);
let LidarrSerdeable::Updates(updates) = network
.handle_lidarr_event(LidarrEvent::GetUpdates)
.await
.unwrap()
else {
panic!("Expected Updates")
};
mock.assert_async().await;
let actual_text = app.lock().await.data.lidarr_data.updates.get_text();
let expected = expected_text.get_text();
// Trim trailing whitespace from each line for comparison
let actual_trimmed: Vec<&str> = actual_text.lines().map(|l| l.trim_end()).collect();
let expected_trimmed: Vec<&str> = expected.lines().map(|l| l.trim_end()).collect();
assert_eq!(
actual_trimmed, expected_trimmed,
"Updates text mismatch (after trimming trailing whitespace)"
);
assert_eq!(updates, response);
}
#[tokio::test]
async fn test_handle_start_lidarr_task_event() {
let response = json!({ "test": "test"});
let (mock, app, _server) = MockServarrApi::post()
.with_request_body(json!({
"name": "ApplicationUpdateCheck"
}))
.returns(response.clone())
.build_for(LidarrEvent::StartTask(
LidarrTaskName::ApplicationUpdateCheck,
))
.await;
app
.lock()
.await
.data
.lidarr_data
.tasks
.set_items(vec![LidarrTask {
task_name: LidarrTaskName::default(),
..LidarrTask::default()
}]);
app.lock().await.server_tabs.set_index(2);
let mut network = test_network(&app);
let LidarrSerdeable::Value(value) = network
.handle_lidarr_event(LidarrEvent::StartTask(
LidarrTaskName::ApplicationUpdateCheck,
))
.await
.unwrap()
else {
panic!("Expected Value")
};
mock.assert_async().await;
assert_eq!(value, response);
}
}
+211 -11
View File
@@ -1,10 +1,14 @@
use anyhow::Result;
use log::info;
use crate::models::lidarr_models::SystemStatus;
use crate::models::servarr_models::{DiskSpace, HostConfig, SecurityConfig};
use crate::models::lidarr_models::{LidarrTask, LidarrTaskName, SystemStatus};
use crate::models::servarr_models::{
CommandBody, DiskSpace, HostConfig, LogResponse, QueueEvent, SecurityConfig, Update,
};
use crate::models::{HorizontallyScrollableText, Scrollable, ScrollableText};
use crate::network::lidarr_network::LidarrEvent;
use crate::network::{Network, RequestMethod};
use anyhow::Result;
use indoc::formatdoc;
use log::info;
use serde_json::Value;
#[cfg(test)]
#[path = "lidarr_system_network_tests.rs"]
@@ -26,18 +30,62 @@ impl Network<'_, '_> {
.await
}
pub(in crate::network::lidarr_network) async fn get_lidarr_security_config(
pub(in crate::network::lidarr_network) async fn get_lidarr_logs(
&mut self,
) -> Result<SecurityConfig> {
info!("Fetching Lidarr security config");
let event = LidarrEvent::GetSecurityConfig;
events: u64,
) -> Result<LogResponse> {
info!("Fetching Lidarr logs");
let event = LidarrEvent::GetLogs(events);
let params = format!("pageSize={events}&sortDirection=descending&sortKey=time");
let request_props = self
.request_props_from(event, RequestMethod::Get, None::<()>, None, None)
.request_props_from(event, RequestMethod::Get, None::<()>, None, Some(params))
.await;
self
.handle_request::<(), SecurityConfig>(request_props, |_, _| ())
.handle_request::<(), LogResponse>(request_props, |log_response, mut app| {
let mut logs = log_response.records;
logs.reverse();
let log_lines = logs
.into_iter()
.map(|log| {
if log.exception.is_some() {
HorizontallyScrollableText::from(format!(
"{}|{}|{}|{}|{}",
log.time,
log.level.to_uppercase(),
log
.logger
.as_ref()
.expect("logger must exist when exception is present"),
log
.exception_type
.as_ref()
.expect("exception_type must exist when exception is present"),
log
.exception
.as_ref()
.expect("exception must exist in this branch")
))
} else {
HorizontallyScrollableText::from(format!(
"{}|{}|{}|{}",
log.time,
log.level.to_uppercase(),
log.logger.as_ref().expect("logger must exist in log entry"),
log
.message
.as_ref()
.expect("message must exist when exception is not present")
))
}
})
.collect();
app.data.lidarr_data.logs.set_items(log_lines);
app.data.lidarr_data.logs.scroll_to_bottom();
})
.await
}
@@ -58,6 +106,42 @@ impl Network<'_, '_> {
.await
}
pub(in crate::network::lidarr_network) async fn get_queued_lidarr_events(
&mut self,
) -> Result<Vec<QueueEvent>> {
info!("Fetching Lidarr queued events");
let event = LidarrEvent::GetQueuedEvents;
let request_props = self
.request_props_from(event, RequestMethod::Get, None::<()>, None, None)
.await;
self
.handle_request::<(), Vec<QueueEvent>>(request_props, |queued_events_vec, mut app| {
app
.data
.lidarr_data
.queued_events
.set_items(queued_events_vec);
})
.await
}
pub(in crate::network::lidarr_network) async fn get_lidarr_security_config(
&mut self,
) -> Result<SecurityConfig> {
info!("Fetching Lidarr security config");
let event = LidarrEvent::GetSecurityConfig;
let request_props = self
.request_props_from(event, RequestMethod::Get, None::<()>, None, None)
.await;
self
.handle_request::<(), SecurityConfig>(request_props, |_, _| ())
.await
}
pub(in crate::network::lidarr_network) async fn get_lidarr_status(
&mut self,
) -> Result<SystemStatus> {
@@ -75,4 +159,120 @@ impl Network<'_, '_> {
})
.await
}
pub(in crate::network::lidarr_network) async fn get_lidarr_tasks(
&mut self,
) -> Result<Vec<LidarrTask>> {
info!("Fetching Lidarr tasks");
let event = LidarrEvent::GetTasks;
let request_props = self
.request_props_from(event, RequestMethod::Get, None::<()>, None, None)
.await;
self
.handle_request::<(), Vec<LidarrTask>>(request_props, |tasks_vec, mut app| {
app.data.lidarr_data.tasks.set_items(tasks_vec);
})
.await
}
pub(in crate::network::lidarr_network) async fn get_lidarr_updates(
&mut self,
) -> Result<Vec<Update>> {
info!("Fetching Lidarr updates");
let event = LidarrEvent::GetUpdates;
let request_props = self
.request_props_from(event, RequestMethod::Get, None::<()>, None, None)
.await;
self
.handle_request::<(), Vec<Update>>(request_props, |updates_vec, mut app| {
let latest_installed = if updates_vec
.iter()
.any(|update| update.latest && update.installed_on.is_some())
{
"already".to_owned()
} else {
"not".to_owned()
};
let updates = updates_vec
.into_iter()
.map(|update| {
let install_status = if update.installed_on.is_some() {
if update.installed {
" (Currently Installed)".to_owned()
} else {
" (Previously Installed)".to_owned()
}
} else {
String::new()
};
let vec_to_bullet_points = |vec: Vec<String>| {
vec
.iter()
.map(|change| format!(" * {change}"))
.collect::<Vec<String>>()
.join("\n")
};
let mut update_info = formatdoc!(
"{} - {}{install_status}
{}",
update.version,
update.release_date,
"-".repeat(200)
);
if let Some(new_changes) = update.changes.new {
let changes = vec_to_bullet_points(new_changes);
update_info = formatdoc!(
"{update_info}
New:
{changes}"
)
}
if let Some(fixes) = update.changes.fixed {
let fixes = vec_to_bullet_points(fixes);
update_info = formatdoc!(
"{update_info}
Fixed:
{fixes}"
);
}
update_info
})
.reduce(|version_1, version_2| format!("{version_1}\n\n\n{version_2}"))
.unwrap();
app.data.lidarr_data.updates = ScrollableText::with_string(formatdoc!(
"The latest version of Lidarr is {latest_installed} installed
{updates}"
));
})
.await
}
pub(in crate::network::lidarr_network) async fn start_lidarr_task(
&mut self,
task: LidarrTaskName,
) -> Result<Value> {
let event = LidarrEvent::StartTask(task);
let task_name = task.to_string();
info!("Starting Lidarr task: {task_name}");
let body = CommandBody { name: task_name };
let request_props = self
.request_props_from(event, RequestMethod::Post, Some(body), None, None)
.await;
self
.handle_request::<CommandBody, Value>(request_props, |_, _| ())
.await
}
}