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)] #[derive(Debug, Clone, PartialEq, Eq, Subcommand)]
pub enum SonarrGetCommand { 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")] #[command(about = "Get the system status")]
SystemStatus, SystemStatus,
} }
@@ -50,6 +59,9 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, SonarrGetCommand> for SonarrGetCommandHan
async fn handle(self) -> Result<()> { async fn handle(self) -> Result<()> {
match self.command { match self.command {
SonarrGetCommand::EpisodeDetails { episode_id } => {
execute_network_event!(self, SonarrEvent::GetEpisodeDetails(Some(episode_id)));
}
SonarrGetCommand::SystemStatus => { SonarrGetCommand::SystemStatus => {
execute_network_event!(self, SonarrEvent::GetStatus); execute_network_event!(self, SonarrEvent::GetStatus);
} }
+55 -1
View File
@@ -17,12 +17,40 @@ mod tests {
} }
mod cli { mod cli {
use clap::error::ErrorKind;
use super::*; use super::*;
#[test] #[test]
fn test_system_status_has_no_arg_requirements() { fn test_system_status_has_no_arg_requirements() {
let result = 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()); assert!(result.is_ok());
} }
@@ -45,6 +73,32 @@ mod tests {
network::{sonarr_network::SonarrEvent, MockNetworkTrait, NetworkEvent}, 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] #[tokio::test]
async fn test_handle_get_system_status_command() { async fn test_handle_get_system_status_command() {
let mut mock_network = MockNetworkTrait::new(); let mut mock_network = MockNetworkTrait::new();
+10
View File
@@ -21,6 +21,8 @@ mod list_command_handler_tests;
pub enum SonarrListCommand { pub enum SonarrListCommand {
#[command(about = "List all items in the Sonarr blocklist")] #[command(about = "List all items in the Sonarr blocklist")]
Blocklist, Blocklist,
#[command(about = "List all active downloads in Sonarr")]
Downloads,
#[command(about = "List the episodes for the series with the given ID")] #[command(about = "List the episodes for the series with the given ID")]
Episodes { Episodes {
#[arg( #[arg(
@@ -40,6 +42,8 @@ pub enum SonarrListCommand {
)] )]
output_in_log_format: bool, output_in_log_format: bool,
}, },
#[command(about = "List all Sonarr quality profiles")]
QualityProfiles,
#[command(about = "List all series in your Sonarr library")] #[command(about = "List all series in your Sonarr library")]
Series, Series,
} }
@@ -74,6 +78,9 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, SonarrListCommand> for SonarrListCommandH
SonarrListCommand::Blocklist => { SonarrListCommand::Blocklist => {
execute_network_event!(self, SonarrEvent::GetBlocklist); execute_network_event!(self, SonarrEvent::GetBlocklist);
} }
SonarrListCommand::Downloads => {
execute_network_event!(self, SonarrEvent::GetDownloads);
}
SonarrListCommand::Episodes { series_id } => { SonarrListCommand::Episodes { series_id } => {
execute_network_event!(self, SonarrEvent::GetEpisodes(Some(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); println!("{}", json);
} }
} }
SonarrListCommand::QualityProfiles => {
execute_network_event!(self, SonarrEvent::GetQualityProfiles);
}
SonarrListCommand::Series => { SonarrListCommand::Series => {
execute_network_event!(self, SonarrEvent::ListSeries); execute_network_event!(self, SonarrEvent::ListSeries);
} }
+3 -1
View File
@@ -24,7 +24,7 @@ mod tests {
#[rstest] #[rstest]
fn test_list_commands_have_no_arg_requirements( 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]); let result = Cli::command().try_get_matches_from(["managarr", "sonarr", "list", subcommand]);
@@ -102,6 +102,8 @@ mod tests {
#[rstest] #[rstest]
#[case(SonarrListCommand::Blocklist, SonarrEvent::GetBlocklist)] #[case(SonarrListCommand::Blocklist, SonarrEvent::GetBlocklist)]
#[case(SonarrListCommand::Downloads, SonarrEvent::GetDownloads)]
#[case(SonarrListCommand::QualityProfiles, SonarrEvent::GetQualityProfiles)]
#[case(SonarrListCommand::Series, SonarrEvent::ListSeries)] #[case(SonarrListCommand::Series, SonarrEvent::ListSeries)]
#[tokio::test] #[tokio::test]
async fn test_handle_list_command( async fn test_handle_list_command(
+8
View File
@@ -56,6 +56,12 @@ pub struct DownloadRecord {
pub download_client: String, 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)] #[derive(Default, Serialize, Deserialize, Hash, Debug, Clone, PartialEq, Eq)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct Episode { pub struct Episode {
@@ -321,6 +327,7 @@ impl SeriesStatus {
#[allow(clippy::large_enum_variant)] #[allow(clippy::large_enum_variant)]
pub enum SonarrSerdeable { pub enum SonarrSerdeable {
Value(Value), Value(Value),
DownloadsResponse(DownloadsResponse),
Episode(Episode), Episode(Episode),
Episodes(Vec<Episode>), Episodes(Vec<Episode>),
QualityProfiles(Vec<QualityProfile>), QualityProfiles(Vec<QualityProfile>),
@@ -345,6 +352,7 @@ impl From<()> for SonarrSerdeable {
serde_enum_from!( serde_enum_from!(
SonarrSerdeable { SonarrSerdeable {
Value(Value), Value(Value),
DownloadsResponse(DownloadsResponse),
Episode(Episode), Episode(Episode),
Episodes(Vec<Episode>), Episodes(Vec<Episode>),
QualityProfiles(Vec<QualityProfile>), QualityProfiles(Vec<QualityProfile>),
+18 -2
View File
@@ -5,8 +5,8 @@ mod tests {
use crate::models::{ use crate::models::{
sonarr_models::{ sonarr_models::{
BlocklistItem, BlocklistResponse, Episode, Log, LogResponse, QualityProfile, Series, BlocklistItem, BlocklistResponse, DownloadRecord, DownloadsResponse, Episode, Log,
SeriesStatus, SeriesType, SonarrSerdeable, SystemStatus, LogResponse, QualityProfile, Series, SeriesStatus, SeriesType, SonarrSerdeable, SystemStatus,
}, },
Serdeable, 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] #[test]
fn test_sonarr_serdeable_from_log_response() { fn test_sonarr_serdeable_from_log_response() {
let log_response = LogResponse { let log_response = LogResponse {
+2 -2
View File
@@ -191,7 +191,7 @@ impl<'a, 'b> Network<'a, 'b> {
.map(RadarrSerdeable::from), .map(RadarrSerdeable::from),
RadarrEvent::GetBlocklist => self.get_radarr_blocklist().await.map(RadarrSerdeable::from), RadarrEvent::GetBlocklist => self.get_radarr_blocklist().await.map(RadarrSerdeable::from),
RadarrEvent::GetCollections => self.get_collections().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::GetHostConfig => self.get_host_config().await.map(RadarrSerdeable::from),
RadarrEvent::GetIndexers => self.get_indexers().await.map(RadarrSerdeable::from), RadarrEvent::GetIndexers => self.get_indexers().await.map(RadarrSerdeable::from),
RadarrEvent::GetLogs(events) => self RadarrEvent::GetLogs(events) => self
@@ -1363,7 +1363,7 @@ impl<'a, 'b> Network<'a, 'b> {
.await .await
} }
async fn get_downloads(&mut self) -> Result<DownloadsResponse> { async fn get_radarr_downloads(&mut self) -> Result<DownloadsResponse> {
info!("Fetching Radarr downloads"); info!("Fetching Radarr downloads");
let event = RadarrEvent::GetDownloads; let event = RadarrEvent::GetDownloads;
+1 -1
View File
@@ -2182,7 +2182,7 @@ mod test {
} }
#[tokio::test] #[tokio::test]
async fn test_handle_get_downloads_event() { async fn test_handle_get_radarr_downloads_event() {
let downloads_response_json = json!({ let downloads_response_json = json!({
"records": [{ "records": [{
"title": "Test Download Title", "title": "Test Download Title",
+24 -2
View File
@@ -10,8 +10,8 @@ use crate::{
models::{ models::{
servarr_data::sonarr::{modals::EpisodeDetailsModal, sonarr_data::ActiveSonarrBlock}, servarr_data::sonarr::{modals::EpisodeDetailsModal, sonarr_data::ActiveSonarrBlock},
sonarr_models::{ sonarr_models::{
BlocklistResponse, DownloadRecord, Episode, LogResponse, QualityProfile, Series, BlocklistResponse, DownloadRecord, DownloadsResponse, Episode, LogResponse, QualityProfile,
SonarrSerdeable, SystemStatus, Series, SonarrSerdeable, SystemStatus,
}, },
HorizontallyScrollableText, Route, Scrollable, ScrollableText, HorizontallyScrollableText, Route, Scrollable, ScrollableText,
}, },
@@ -29,6 +29,7 @@ pub enum SonarrEvent {
ClearBlocklist, ClearBlocklist,
DeleteBlocklistItem(Option<i64>), DeleteBlocklistItem(Option<i64>),
GetBlocklist, GetBlocklist,
GetDownloads,
GetEpisodeDetails(Option<i64>), GetEpisodeDetails(Option<i64>),
GetEpisodes(Option<i64>), GetEpisodes(Option<i64>),
GetLogs(Option<u64>), GetLogs(Option<u64>),
@@ -44,6 +45,7 @@ impl NetworkResource for SonarrEvent {
SonarrEvent::ClearBlocklist => "/blocklist/bulk", SonarrEvent::ClearBlocklist => "/blocklist/bulk",
SonarrEvent::DeleteBlocklistItem(_) => "/blocklist", SonarrEvent::DeleteBlocklistItem(_) => "/blocklist",
SonarrEvent::GetBlocklist => "/blocklist?page=1&pageSize=10000", SonarrEvent::GetBlocklist => "/blocklist?page=1&pageSize=10000",
SonarrEvent::GetDownloads => "/queue",
SonarrEvent::GetEpisodes(_) | SonarrEvent::GetEpisodeDetails(_) => "/episode", SonarrEvent::GetEpisodes(_) | SonarrEvent::GetEpisodeDetails(_) => "/episode",
SonarrEvent::GetLogs(_) => "/log", SonarrEvent::GetLogs(_) => "/log",
SonarrEvent::GetQualityProfiles => "/qualityprofile", SonarrEvent::GetQualityProfiles => "/qualityprofile",
@@ -75,6 +77,7 @@ impl<'a, 'b> Network<'a, 'b> {
.await .await
.map(SonarrSerdeable::from), .map(SonarrSerdeable::from),
SonarrEvent::GetBlocklist => self.get_sonarr_blocklist().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 SonarrEvent::GetEpisodes(series_id) => self
.get_episodes(series_id) .get_episodes(series_id)
.await .await
@@ -200,6 +203,25 @@ impl<'a, 'b> Network<'a, 'b> {
.await .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>> { async fn get_episodes(&mut self, series_id: Option<i64>) -> Result<Vec<Episode>> {
let event = SonarrEvent::GetEpisodes(series_id); let event = SonarrEvent::GetEpisodes(series_id);
let (id, series_id_param) = self.extract_series_id(series_id).await; 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::app::App;
use crate::models::servarr_data::sonarr::sonarr_data::ActiveSonarrBlock; use crate::models::servarr_data::sonarr::sonarr_data::ActiveSonarrBlock;
use crate::models::sonarr_models::{ use crate::models::sonarr_models::{
BlocklistItem, DownloadRecord, Episode, EpisodeFile, Language, LogResponse, MediaInfo, BlocklistItem, DownloadRecord, DownloadsResponse, Episode, EpisodeFile, Language, LogResponse,
QualityProfile, MediaInfo, QualityProfile,
}; };
use crate::models::sonarr_models::{BlocklistResponse, Quality}; use crate::models::sonarr_models::{BlocklistResponse, Quality};
use crate::models::sonarr_models::{QualityWrapper, SystemStatus}; use crate::models::sonarr_models::{QualityWrapper, SystemStatus};
@@ -142,6 +142,7 @@ mod test {
#[case(SonarrEvent::DeleteBlocklistItem(None), "/blocklist")] #[case(SonarrEvent::DeleteBlocklistItem(None), "/blocklist")]
#[case(SonarrEvent::HealthCheck, "/health")] #[case(SonarrEvent::HealthCheck, "/health")]
#[case(SonarrEvent::GetBlocklist, "/blocklist?page=1&pageSize=10000")] #[case(SonarrEvent::GetBlocklist, "/blocklist?page=1&pageSize=10000")]
#[case(SonarrEvent::GetDownloads, "/queue")]
#[case(SonarrEvent::GetLogs(Some(500)), "/log")] #[case(SonarrEvent::GetLogs(Some(500)), "/log")]
#[case(SonarrEvent::GetQualityProfiles, "/qualityprofile")] #[case(SonarrEvent::GetQualityProfiles, "/qualityprofile")]
#[case(SonarrEvent::GetStatus, "/system/status")] #[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] #[tokio::test]
async fn test_handle_get_sonarr_healthcheck_event() { async fn test_handle_get_sonarr_healthcheck_event() {
let (async_server, app_arc, _server) = mock_servarr_api( 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 { fn episode() -> Episode {
Episode { Episode {
id: 1, id: 1,