feat(cli): Added sonarr support for listing downloads, listing quality profiles, and fetching detailed information about an episode

This commit is contained in:
2024-11-15 18:41:13 -07:00
parent e14b7072c6
commit 003f319385
10 changed files with 201 additions and 11 deletions
+12
View File
@@ -19,6 +19,15 @@ mod get_command_handler_tests;
#[derive(Debug, Clone, PartialEq, Eq, Subcommand)]
pub enum SonarrGetCommand {
#[command(about = "Get detailed information for the episode with the given ID")]
EpisodeDetails {
#[arg(
long,
help = "The Sonarr ID of the episode whose details you wish to fetch",
required = true
)]
episode_id: i64,
},
#[command(about = "Get the system status")]
SystemStatus,
}
@@ -50,6 +59,9 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, SonarrGetCommand> for SonarrGetCommandHan
async fn handle(self) -> Result<()> {
match self.command {
SonarrGetCommand::EpisodeDetails { episode_id } => {
execute_network_event!(self, SonarrEvent::GetEpisodeDetails(Some(episode_id)));
}
SonarrGetCommand::SystemStatus => {
execute_network_event!(self, SonarrEvent::GetStatus);
}
+55 -1
View File
@@ -17,12 +17,40 @@ mod tests {
}
mod cli {
use clap::error::ErrorKind;
use super::*;
#[test]
fn test_system_status_has_no_arg_requirements() {
let result =
Cli::command().try_get_matches_from(["managarr", "radarr", "get", "system-status"]);
Cli::command().try_get_matches_from(["managarr", "sonarr", "get", "system-status"]);
assert!(result.is_ok());
}
#[test]
fn test_episode_details_requires_episode_id() {
let result =
Cli::command().try_get_matches_from(["managarr", "sonarr", "get", "episode-details"]);
assert!(result.is_err());
assert_eq!(
result.unwrap_err().kind(),
ErrorKind::MissingRequiredArgument
);
}
#[test]
fn test_episode_details_requirements_satisfied() {
let result = Cli::command().try_get_matches_from([
"managarr",
"sonarr",
"get",
"episode-details",
"--episode-id",
"1",
]);
assert!(result.is_ok());
}
@@ -45,6 +73,32 @@ mod tests {
network::{sonarr_network::SonarrEvent, MockNetworkTrait, NetworkEvent},
};
#[tokio::test]
async fn test_handle_get_episode_details_command() {
let expected_episode_id = 1;
let mut mock_network = MockNetworkTrait::new();
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(
SonarrEvent::GetEpisodeDetails(Some(expected_episode_id)).into(),
))
.times(1)
.returning(|_| {
Ok(Serdeable::Sonarr(SonarrSerdeable::Value(
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::default()));
let get_episode_details_command = SonarrGetCommand::EpisodeDetails { episode_id: 1 };
let result =
SonarrGetCommandHandler::with(&app_arc, get_episode_details_command, &mut mock_network)
.handle()
.await;
assert!(result.is_ok());
}
#[tokio::test]
async fn test_handle_get_system_status_command() {
let mut mock_network = MockNetworkTrait::new();
+10
View File
@@ -21,6 +21,8 @@ mod list_command_handler_tests;
pub enum SonarrListCommand {
#[command(about = "List all items in the Sonarr blocklist")]
Blocklist,
#[command(about = "List all active downloads in Sonarr")]
Downloads,
#[command(about = "List the episodes for the series with the given ID")]
Episodes {
#[arg(
@@ -40,6 +42,8 @@ pub enum SonarrListCommand {
)]
output_in_log_format: bool,
},
#[command(about = "List all Sonarr quality profiles")]
QualityProfiles,
#[command(about = "List all series in your Sonarr library")]
Series,
}
@@ -74,6 +78,9 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, SonarrListCommand> for SonarrListCommandH
SonarrListCommand::Blocklist => {
execute_network_event!(self, SonarrEvent::GetBlocklist);
}
SonarrListCommand::Downloads => {
execute_network_event!(self, SonarrEvent::GetDownloads);
}
SonarrListCommand::Episodes { series_id } => {
execute_network_event!(self, SonarrEvent::GetEpisodes(Some(series_id)));
}
@@ -96,6 +103,9 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, SonarrListCommand> for SonarrListCommandH
println!("{}", json);
}
}
SonarrListCommand::QualityProfiles => {
execute_network_event!(self, SonarrEvent::GetQualityProfiles);
}
SonarrListCommand::Series => {
execute_network_event!(self, SonarrEvent::ListSeries);
}
+3 -1
View File
@@ -24,7 +24,7 @@ mod tests {
#[rstest]
fn test_list_commands_have_no_arg_requirements(
#[values("blocklist", "series")] subcommand: &str,
#[values("blocklist", "series", "downloads", "quality-profiles")] subcommand: &str,
) {
let result = Cli::command().try_get_matches_from(["managarr", "sonarr", "list", subcommand]);
@@ -102,6 +102,8 @@ mod tests {
#[rstest]
#[case(SonarrListCommand::Blocklist, SonarrEvent::GetBlocklist)]
#[case(SonarrListCommand::Downloads, SonarrEvent::GetDownloads)]
#[case(SonarrListCommand::QualityProfiles, SonarrEvent::GetQualityProfiles)]
#[case(SonarrListCommand::Series, SonarrEvent::ListSeries)]
#[tokio::test]
async fn test_handle_list_command(
+8
View File
@@ -56,6 +56,12 @@ pub struct DownloadRecord {
pub download_client: String,
}
#[derive(Default, Clone, Serialize, Deserialize, Debug, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct DownloadsResponse {
pub records: Vec<DownloadRecord>,
}
#[derive(Default, Serialize, Deserialize, Hash, Debug, Clone, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct Episode {
@@ -321,6 +327,7 @@ impl SeriesStatus {
#[allow(clippy::large_enum_variant)]
pub enum SonarrSerdeable {
Value(Value),
DownloadsResponse(DownloadsResponse),
Episode(Episode),
Episodes(Vec<Episode>),
QualityProfiles(Vec<QualityProfile>),
@@ -345,6 +352,7 @@ impl From<()> for SonarrSerdeable {
serde_enum_from!(
SonarrSerdeable {
Value(Value),
DownloadsResponse(DownloadsResponse),
Episode(Episode),
Episodes(Vec<Episode>),
QualityProfiles(Vec<QualityProfile>),
+18 -2
View File
@@ -5,8 +5,8 @@ mod tests {
use crate::models::{
sonarr_models::{
BlocklistItem, BlocklistResponse, Episode, Log, LogResponse, QualityProfile, Series,
SeriesStatus, SeriesType, SonarrSerdeable, SystemStatus,
BlocklistItem, BlocklistResponse, DownloadRecord, DownloadsResponse, Episode, Log,
LogResponse, QualityProfile, Series, SeriesStatus, SeriesType, SonarrSerdeable, SystemStatus,
},
Serdeable,
};
@@ -145,6 +145,22 @@ mod tests {
);
}
#[test]
fn test_sonarr_serdeable_from_downloads_response() {
let downloads_response = DownloadsResponse {
records: vec![DownloadRecord {
id: 1,
..DownloadRecord::default()
}],
};
let sonarr_serdeable: SonarrSerdeable = downloads_response.clone().into();
assert_eq!(
sonarr_serdeable,
SonarrSerdeable::DownloadsResponse(downloads_response)
);
}
#[test]
fn test_sonarr_serdeable_from_log_response() {
let log_response = LogResponse {
+2 -2
View File
@@ -191,7 +191,7 @@ impl<'a, 'b> Network<'a, 'b> {
.map(RadarrSerdeable::from),
RadarrEvent::GetBlocklist => self.get_radarr_blocklist().await.map(RadarrSerdeable::from),
RadarrEvent::GetCollections => self.get_collections().await.map(RadarrSerdeable::from),
RadarrEvent::GetDownloads => self.get_downloads().await.map(RadarrSerdeable::from),
RadarrEvent::GetDownloads => self.get_radarr_downloads().await.map(RadarrSerdeable::from),
RadarrEvent::GetHostConfig => self.get_host_config().await.map(RadarrSerdeable::from),
RadarrEvent::GetIndexers => self.get_indexers().await.map(RadarrSerdeable::from),
RadarrEvent::GetLogs(events) => self
@@ -1363,7 +1363,7 @@ impl<'a, 'b> Network<'a, 'b> {
.await
}
async fn get_downloads(&mut self) -> Result<DownloadsResponse> {
async fn get_radarr_downloads(&mut self) -> Result<DownloadsResponse> {
info!("Fetching Radarr downloads");
let event = RadarrEvent::GetDownloads;
+1 -1
View File
@@ -2182,7 +2182,7 @@ mod test {
}
#[tokio::test]
async fn test_handle_get_downloads_event() {
async fn test_handle_get_radarr_downloads_event() {
let downloads_response_json = json!({
"records": [{
"title": "Test Download Title",
+24 -2
View File
@@ -10,8 +10,8 @@ use crate::{
models::{
servarr_data::sonarr::{modals::EpisodeDetailsModal, sonarr_data::ActiveSonarrBlock},
sonarr_models::{
BlocklistResponse, DownloadRecord, Episode, LogResponse, QualityProfile, Series,
SonarrSerdeable, SystemStatus,
BlocklistResponse, DownloadRecord, DownloadsResponse, Episode, LogResponse, QualityProfile,
Series, SonarrSerdeable, SystemStatus,
},
HorizontallyScrollableText, Route, Scrollable, ScrollableText,
},
@@ -29,6 +29,7 @@ pub enum SonarrEvent {
ClearBlocklist,
DeleteBlocklistItem(Option<i64>),
GetBlocklist,
GetDownloads,
GetEpisodeDetails(Option<i64>),
GetEpisodes(Option<i64>),
GetLogs(Option<u64>),
@@ -44,6 +45,7 @@ impl NetworkResource for SonarrEvent {
SonarrEvent::ClearBlocklist => "/blocklist/bulk",
SonarrEvent::DeleteBlocklistItem(_) => "/blocklist",
SonarrEvent::GetBlocklist => "/blocklist?page=1&pageSize=10000",
SonarrEvent::GetDownloads => "/queue",
SonarrEvent::GetEpisodes(_) | SonarrEvent::GetEpisodeDetails(_) => "/episode",
SonarrEvent::GetLogs(_) => "/log",
SonarrEvent::GetQualityProfiles => "/qualityprofile",
@@ -75,6 +77,7 @@ impl<'a, 'b> Network<'a, 'b> {
.await
.map(SonarrSerdeable::from),
SonarrEvent::GetBlocklist => self.get_sonarr_blocklist().await.map(SonarrSerdeable::from),
SonarrEvent::GetDownloads => self.get_sonarr_downloads().await.map(SonarrSerdeable::from),
SonarrEvent::GetEpisodes(series_id) => self
.get_episodes(series_id)
.await
@@ -200,6 +203,25 @@ impl<'a, 'b> Network<'a, 'b> {
.await
}
async fn get_sonarr_downloads(&mut self) -> Result<DownloadsResponse> {
info!("Fetching Sonarr downloads");
let event = SonarrEvent::GetDownloads;
let request_props = self
.request_props_from(event, RequestMethod::Get, None::<()>, None, None)
.await;
self
.handle_request::<(), DownloadsResponse>(request_props, |queue_response, mut app| {
app
.data
.sonarr_data
.downloads
.set_items(queue_response.records);
})
.await
}
async fn get_episodes(&mut self, series_id: Option<i64>) -> Result<Vec<Episode>> {
let event = SonarrEvent::GetEpisodes(series_id);
let (id, series_id_param) = self.extract_series_id(series_id).await;
+68 -2
View File
@@ -23,8 +23,8 @@ mod test {
use crate::app::App;
use crate::models::servarr_data::sonarr::sonarr_data::ActiveSonarrBlock;
use crate::models::sonarr_models::{
BlocklistItem, DownloadRecord, Episode, EpisodeFile, Language, LogResponse, MediaInfo,
QualityProfile,
BlocklistItem, DownloadRecord, DownloadsResponse, Episode, EpisodeFile, Language, LogResponse,
MediaInfo, QualityProfile,
};
use crate::models::sonarr_models::{BlocklistResponse, Quality};
use crate::models::sonarr_models::{QualityWrapper, SystemStatus};
@@ -142,6 +142,7 @@ mod test {
#[case(SonarrEvent::DeleteBlocklistItem(None), "/blocklist")]
#[case(SonarrEvent::HealthCheck, "/health")]
#[case(SonarrEvent::GetBlocklist, "/blocklist?page=1&pageSize=10000")]
#[case(SonarrEvent::GetDownloads, "/queue")]
#[case(SonarrEvent::GetLogs(Some(500)), "/log")]
#[case(SonarrEvent::GetQualityProfiles, "/qualityprofile")]
#[case(SonarrEvent::GetStatus, "/system/status")]
@@ -322,6 +323,49 @@ mod test {
}
}
#[tokio::test]
async fn test_handle_get_sonarr_downloads_event() {
let downloads_response_json = json!({
"records": [{
"title": "Test Download Title",
"status": "downloading",
"id": 1,
"episodeId": 1,
"size": 3543348019u64,
"sizeleft": 1771674009,
"outputPath": "/nfs/tv/Test show/season 1/",
"indexer": "kickass torrents",
"downloadClient": "transmission",
}]
});
let response: DownloadsResponse =
serde_json::from_value(downloads_response_json.clone()).unwrap();
let (async_server, app_arc, _server) = mock_servarr_api(
RequestMethod::Get,
None,
Some(downloads_response_json),
None,
SonarrEvent::GetDownloads,
None,
None,
)
.await;
let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new());
if let SonarrSerdeable::DownloadsResponse(downloads) = network
.handle_sonarr_event(SonarrEvent::GetDownloads)
.await
.unwrap()
{
async_server.assert_async().await;
assert_eq!(
app_arc.lock().await.data.sonarr_data.downloads.items,
downloads_response().records
);
assert_eq!(downloads, response);
}
}
#[tokio::test]
async fn test_handle_get_sonarr_healthcheck_event() {
let (async_server, app_arc, _server) = mock_servarr_api(
@@ -1403,6 +1447,28 @@ mod test {
}
}
fn download_record() -> DownloadRecord {
DownloadRecord {
title: "Test Download Title".to_owned(),
status: "downloading".to_owned(),
id: 1,
episode_id: 1,
size: 3543348019,
sizeleft: 1771674009,
output_path: Some(HorizontallyScrollableText::from(
"/nfs/tv/Test show/season 1/",
)),
indexer: "kickass torrents".to_owned(),
download_client: "transmission".to_owned(),
}
}
fn downloads_response() -> DownloadsResponse {
DownloadsResponse {
records: vec![download_record()],
}
}
fn episode() -> Episode {
Episode {
id: 1,