feat(readarr): fetch Readarr logs and expose them on the CLI

Adds ReadarrEvent::GetLogs backed by get_readarr_logs, which formats each
record into the Readarr logs list, and surfaces it as
'readarr list logs --events N'.

The --output-in-log-format branch reads the Readarr log lines, and is
covered by a test that pins that source so it cannot silently drift to
another servarr's data.
This commit is contained in:
Dark-Alex-17 committed 2026-08-20 21:33:57 -06:00
1 parent c6d729162a
commit 690a17e152
6 files changed
+276 -9

No files matched your search

+30 -3
View File
@@ -19,6 +19,16 @@ mod list_command_handler_tests;
pub enum ReadarrListCommand { pub enum ReadarrListCommand {
#[command(about = "List disk space details for all provisioned root folders in Readarr")] #[command(about = "List disk space details for all provisioned root folders in Readarr")]
DiskSpace, DiskSpace,
#[command(about = "Fetch Readarr logs")]
Logs {
#[arg(long, help = "How many log events to fetch", default_value_t = 500)]
events: u64,
#[arg(
long,
help = "Output the logs in the same format as they appear in the log files"
)]
output_in_log_format: bool,
},
#[command(about = "List all Readarr updates")] #[command(about = "List all Readarr updates")]
Updates, Updates,
} }
@@ -30,19 +40,19 @@ impl From<ReadarrListCommand> for Command {
} }
pub(super) struct ReadarrListCommandHandler<'a, 'b> { pub(super) struct ReadarrListCommandHandler<'a, 'b> {
_app: &'a Arc<Mutex<App<'b>>>, app: &'a Arc<Mutex<App<'b>>>,
command: ReadarrListCommand, command: ReadarrListCommand,
network: &'a mut dyn NetworkTrait, network: &'a mut dyn NetworkTrait,
} }
impl<'a, 'b> CliCommandHandler<'a, 'b, ReadarrListCommand> for ReadarrListCommandHandler<'a, 'b> { impl<'a, 'b> CliCommandHandler<'a, 'b, ReadarrListCommand> for ReadarrListCommandHandler<'a, 'b> {
fn with( fn with(
_app: &'a Arc<Mutex<App<'b>>>, app: &'a Arc<Mutex<App<'b>>>,
command: ReadarrListCommand, command: ReadarrListCommand,
network: &'a mut dyn NetworkTrait, network: &'a mut dyn NetworkTrait,
) -> Self { ) -> Self {
ReadarrListCommandHandler { ReadarrListCommandHandler {
_app, app,
command, command,
network, network,
} }
@@ -57,6 +67,23 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, ReadarrListCommand> for ReadarrListComman
.await?; .await?;
serde_json::to_string_pretty(&resp)? serde_json::to_string_pretty(&resp)?
} }
ReadarrListCommand::Logs {
events,
output_in_log_format,
} => {
let logs = self
.network
.handle_network_event(ReadarrEvent::GetLogs(events).into())
.await?;
if output_in_log_format {
let log_lines = &self.app.lock().await.data.readarr_data.logs.items;
serde_json::to_string_pretty(log_lines)?
} else {
serde_json::to_string_pretty(&logs)?
}
}
ReadarrListCommand::Updates => { ReadarrListCommand::Updates => {
let resp = self let resp = self
.network .network
+92 -3
View File
@@ -18,9 +18,10 @@ mod tests {
} }
mod cli { mod cli {
use rstest::rstest;
use super::*; use super::*;
use clap::{Parser, error::ErrorKind};
use pretty_assertions::assert_eq;
use rstest::rstest;
#[rstest] #[rstest]
fn test_list_commands_have_no_arg_requirements( fn test_list_commands_have_no_arg_requirements(
@@ -30,12 +31,39 @@ mod tests {
assert_ok!(&result); assert_ok!(&result);
} }
#[test]
fn test_list_logs_events_flag_requires_arguments() {
let result =
Cli::command().try_get_matches_from(["managarr", "readarr", "list", "logs", "--events"]);
assert_err!(&result);
assert_eq!(result.unwrap_err().kind(), ErrorKind::InvalidValue);
}
#[test]
fn test_list_logs_default_values() {
let expected_args = ReadarrListCommand::Logs {
events: 500,
output_in_log_format: false,
};
let result = Cli::try_parse_from(["managarr", "readarr", "list", "logs"]);
assert_ok!(&result);
let Some(Command::Readarr(ReadarrCommand::List(logs_command))) = result.unwrap().command
else {
panic!("Unexpected command type");
};
assert_eq!(logs_command, expected_args);
}
} }
mod handler { mod handler {
use std::sync::Arc; use std::sync::Arc;
use mockall::predicate::eq; use mockall::predicate::eq;
use pretty_assertions::assert_str_eq;
use serde_json::json; use serde_json::json;
use tokio::sync::Mutex; use tokio::sync::Mutex;
@@ -45,7 +73,7 @@ mod tests {
CliCommandHandler, CliCommandHandler,
readarr::list_command_handler::{ReadarrListCommand, ReadarrListCommandHandler}, readarr::list_command_handler::{ReadarrListCommand, ReadarrListCommandHandler},
}, },
models::{Serdeable, readarr_models::ReadarrSerdeable}, models::{HorizontallyScrollableText, Serdeable, readarr_models::ReadarrSerdeable},
network::{MockNetworkTrait, NetworkEvent, readarr_network::ReadarrEvent}, network::{MockNetworkTrait, NetworkEvent, readarr_network::ReadarrEvent},
}; };
@@ -72,6 +100,67 @@ mod tests {
assert_ok!(&result); assert_ok!(&result);
} }
#[tokio::test]
async fn test_handle_list_logs_command() {
let expected_events = 1000;
let mut mock_network = MockNetworkTrait::new();
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(
ReadarrEvent::GetLogs(expected_events).into(),
))
.times(1)
.returning(|_| {
Ok(Serdeable::Readarr(ReadarrSerdeable::Value(
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::test_default()));
let list_logs_command = ReadarrListCommand::Logs {
events: 1000,
output_in_log_format: false,
};
let result = ReadarrListCommandHandler::with(&app_arc, list_logs_command, &mut mock_network)
.handle()
.await;
assert_ok!(&result);
}
#[tokio::test]
async fn test_handle_list_logs_command_output_in_log_format() {
let mut mock_network = MockNetworkTrait::new();
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(ReadarrEvent::GetLogs(1000).into()))
.times(1)
.returning(|_| {
Ok(Serdeable::Readarr(ReadarrSerdeable::Value(
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::test_default()));
app_arc
.lock()
.await
.data
.readarr_data
.logs
.set_items(vec![HorizontallyScrollableText::from("readarr log line")]);
let list_logs_command = ReadarrListCommand::Logs {
events: 1000,
output_in_log_format: true,
};
let result = ReadarrListCommandHandler::with(&app_arc, list_logs_command, &mut mock_network)
.handle()
.await;
assert_ok!(&result);
assert_str_eq!(result.unwrap(), "[\n \"readarr log line\"\n]");
}
#[tokio::test] #[tokio::test]
async fn test_handle_list_updates_command() { async fn test_handle_list_updates_command() {
let mut mock_network = MockNetworkTrait::new(); let mut mock_network = MockNetworkTrait::new();
+6
View File
@@ -15,6 +15,7 @@ mod readarr_network_tests;
pub enum ReadarrEvent { pub enum ReadarrEvent {
GetDiskSpace, GetDiskSpace,
GetHostConfig, GetHostConfig,
GetLogs(u64),
GetSecurityConfig, GetSecurityConfig,
GetStatus, GetStatus,
GetUpdates, GetUpdates,
@@ -27,6 +28,7 @@ impl NetworkResource for ReadarrEvent {
ReadarrEvent::GetHostConfig | ReadarrEvent::GetSecurityConfig => "/config/host", ReadarrEvent::GetHostConfig | ReadarrEvent::GetSecurityConfig => "/config/host",
ReadarrEvent::GetDiskSpace => "/diskspace", ReadarrEvent::GetDiskSpace => "/diskspace",
ReadarrEvent::HealthCheck => "/health", ReadarrEvent::HealthCheck => "/health",
ReadarrEvent::GetLogs(_) => "/log",
ReadarrEvent::GetStatus => "/system/status", ReadarrEvent::GetStatus => "/system/status",
ReadarrEvent::GetUpdates => "/update", ReadarrEvent::GetUpdates => "/update",
} }
@@ -53,6 +55,10 @@ impl Network<'_, '_> {
.get_readarr_host_config() .get_readarr_host_config()
.await .await
.map(ReadarrSerdeable::from), .map(ReadarrSerdeable::from),
ReadarrEvent::GetLogs(events) => self
.get_readarr_logs(events)
.await
.map(ReadarrSerdeable::from),
ReadarrEvent::GetSecurityConfig => self ReadarrEvent::GetSecurityConfig => self
.get_readarr_security_config() .get_readarr_security_config()
.await .await
@@ -10,6 +10,7 @@ mod tests {
#[case(ReadarrEvent::GetSecurityConfig, "/config/host")] #[case(ReadarrEvent::GetSecurityConfig, "/config/host")]
#[case(ReadarrEvent::GetDiskSpace, "/diskspace")] #[case(ReadarrEvent::GetDiskSpace, "/diskspace")]
#[case(ReadarrEvent::HealthCheck, "/health")] #[case(ReadarrEvent::HealthCheck, "/health")]
#[case(ReadarrEvent::GetLogs(500), "/log")]
#[case(ReadarrEvent::GetStatus, "/system/status")] #[case(ReadarrEvent::GetStatus, "/system/status")]
#[case(ReadarrEvent::GetUpdates, "/update")] #[case(ReadarrEvent::GetUpdates, "/update")]
fn test_resource(#[case] event: ReadarrEvent, #[case] expected_uri: &str) { fn test_resource(#[case] event: ReadarrEvent, #[case] expected_uri: &str) {
+60 -2
View File
@@ -1,5 +1,7 @@
use crate::models::ScrollableText; use crate::models::servarr_models::{
use crate::models::servarr_models::{DiskSpace, HostConfig, SecurityConfig, SystemStatus, Update}; DiskSpace, HostConfig, LogResponse, SecurityConfig, SystemStatus, Update,
};
use crate::models::{HorizontallyScrollableText, Scrollable, ScrollableText};
use crate::network::readarr_network::ReadarrEvent; use crate::network::readarr_network::ReadarrEvent;
use crate::network::{Network, RequestMethod}; use crate::network::{Network, RequestMethod};
use anyhow::Result; use anyhow::Result;
@@ -43,6 +45,62 @@ impl Network<'_, '_> {
.await .await
} }
pub(in crate::network::readarr_network) async fn get_readarr_logs(
&mut self,
events: u64,
) -> Result<LogResponse> {
info!("Fetching Readarr logs");
let event = ReadarrEvent::GetLogs(events);
let params = format!("pageSize={events}&sortDirection=descending&sortKey=time");
let request_props = self
.request_props_from(event, RequestMethod::Get, None::<()>, None, Some(params))
.await;
self
.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 let Some(exception) = log.exception {
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"),
exception
))
} 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.readarr_data.logs.set_items(log_lines);
app.data.readarr_data.logs.scroll_to_bottom();
})
.await
}
pub(in crate::network::readarr_network) async fn get_readarr_security_config( pub(in crate::network::readarr_network) async fn get_readarr_security_config(
&mut self, &mut self,
) -> Result<SecurityConfig> { ) -> Result<SecurityConfig> {
@@ -4,7 +4,7 @@ mod tests {
use crate::models::readarr_models::ReadarrSerdeable; use crate::models::readarr_models::ReadarrSerdeable;
use crate::models::servarr_models::{ use crate::models::servarr_models::{
AuthenticationMethod, AuthenticationRequired, CertificateValidation, DiskSpace, HostConfig, AuthenticationMethod, AuthenticationRequired, CertificateValidation, DiskSpace, HostConfig,
SecurityConfig, SystemStatus, Update, LogResponse, SecurityConfig, SystemStatus, Update,
}; };
use crate::network::network_tests::test_utils::{MockServarrApi, test_network}; use crate::network::network_tests::test_utils::{MockServarrApi, test_network};
use crate::network::readarr_network::ReadarrEvent; use crate::network::readarr_network::ReadarrEvent;
@@ -145,6 +145,92 @@ mod tests {
assert_err!(result); assert_err!(result);
} }
#[tokio::test]
async fn test_handle_get_readarr_logs_event() {
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": "ReadarrError",
"exception": "test exception",
"exceptionType": "Some.Big.Bad.Exception",
"id": 2
}
]
});
let response: LogResponse = serde_json::from_value(logs_response_json.clone()).unwrap();
let expected_logs = vec![
HorizontallyScrollableText::from(
"2023-05-20 21:29:16 UTC|FATAL|ReadarrError|Some.Big.Bad.Exception|test exception",
),
HorizontallyScrollableText::from("2023-05-20 21:29:16 UTC|INFO|TestLogger|test message"),
];
let (mock, app, _server) = MockServarrApi::get()
.returns(logs_response_json)
.query("pageSize=500&sortDirection=descending&sortKey=time")
.build_for(ReadarrEvent::GetLogs(500))
.await;
app.lock().await.server_tabs.set_index(3);
let mut network = test_network(&app);
let result = network
.handle_readarr_event(ReadarrEvent::GetLogs(500))
.await;
mock.assert_async().await;
let ReadarrSerdeable::LogResponse(logs) = result.unwrap() else {
panic!("Expected LogResponse")
};
assert_eq!(logs, response);
assert_eq!(app.lock().await.data.readarr_data.logs.items, expected_logs);
assert_str_eq!(
app
.lock()
.await
.data
.readarr_data
.logs
.current_selection()
.text,
"2023-05-20 21:29:16 UTC|INFO|TestLogger|test message"
);
}
#[tokio::test]
async fn test_handle_get_readarr_logs_event_failure() {
let (mock, app, _server) = MockServarrApi::get()
.returns(json!({}))
.status(500)
.query("pageSize=500&sortDirection=descending&sortKey=time")
.build_for(ReadarrEvent::GetLogs(500))
.await;
app.lock().await.server_tabs.set_index(3);
let mut network = test_network(&app);
let result = network
.handle_readarr_event(ReadarrEvent::GetLogs(500))
.await;
mock.assert_async().await;
assert_err!(result);
assert_is_empty!(app.lock().await.data.readarr_data.logs);
}
#[tokio::test] #[tokio::test]
async fn test_handle_get_readarr_security_config_event() { async fn test_handle_get_readarr_security_config_event() {
let (mock, app, _server) = MockServarrApi::get() let (mock, app, _server) = MockServarrApi::get()