From c29e2ca9aeb7c659b6c4fd181ce5c035e506472a Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Mon, 25 Nov 2024 16:02:49 -0700 Subject: [PATCH] feat(network): Support for editing a series in Sonarr --- src/network/sonarr_network.rs | 205 +++++++++++++++++++++++++- src/network/sonarr_network_tests.rs | 216 +++++++++++++++++++++++++++- 2 files changed, 413 insertions(+), 8 deletions(-) diff --git a/src/network/sonarr_network.rs b/src/network/sonarr_network.rs index 20cbecc..02397fe 100644 --- a/src/network/sonarr_network.rs +++ b/src/network/sonarr_network.rs @@ -10,7 +10,7 @@ use crate::{ servarr_data::{ modals::{EditIndexerModal, IndexerTestResultModalItem}, sonarr::{ - modals::{AddSeriesModal, EpisodeDetailsModal, SeasonDetailsModal}, + modals::{AddSeriesModal, EditSeriesModal, EpisodeDetailsModal, SeasonDetailsModal}, sonarr_data::ActiveSonarrBlock, }, }, @@ -20,9 +20,10 @@ use crate::{ }, sonarr_models::{ AddSeriesBody, AddSeriesOptions, AddSeriesSearchResult, BlocklistResponse, - DeleteSeriesParams, DownloadRecord, DownloadsResponse, Episode, IndexerSettings, Series, - SonarrCommandBody, SonarrHistoryItem, SonarrHistoryWrapper, SonarrRelease, - SonarrReleaseDownloadBody, SonarrSerdeable, SonarrTask, SonarrTaskName, SystemStatus, + DeleteSeriesParams, DownloadRecord, DownloadsResponse, EditSeriesParams, Episode, + IndexerSettings, Series, SonarrCommandBody, SonarrHistoryItem, SonarrHistoryWrapper, + SonarrRelease, SonarrReleaseDownloadBody, SonarrSerdeable, SonarrTask, SonarrTaskName, + SystemStatus, }, stateful_table::StatefulTable, HorizontallyScrollableText, Route, Scrollable, ScrollableText, @@ -52,6 +53,7 @@ pub enum SonarrEvent { DownloadRelease(SonarrReleaseDownloadBody), EditAllIndexerSettings(Option), EditIndexer(Option), + EditSeries(Option), GetAllIndexerSettings, GetBlocklist, GetDownloads, @@ -134,7 +136,8 @@ impl NetworkResource for SonarrEvent { SonarrEvent::AddSeries(_) | SonarrEvent::ListSeries | SonarrEvent::GetSeriesDetails(_) - | SonarrEvent::DeleteSeries(_) => "/series", + | SonarrEvent::DeleteSeries(_) + | SonarrEvent::EditSeries(_) => "/series", SonarrEvent::SearchNewSeries(_) => "/series/lookup", SonarrEvent::MarkHistoryItemAsFailed(_) => "/history/failed", SonarrEvent::TestIndexer(_) => "/indexer/test", @@ -211,6 +214,10 @@ impl<'a, 'b> Network<'a, 'b> { .edit_sonarr_indexer(params) .await .map(SonarrSerdeable::from), + SonarrEvent::EditSeries(params) => self + .edit_sonarr_series(params) + .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 @@ -1062,6 +1069,194 @@ impl<'a, 'b> Network<'a, 'b> { .await } + async fn edit_sonarr_series( + &mut self, + edit_series_params: Option, + ) -> Result<()> { + info!("Editing Sonarr series"); + let detail_event = SonarrEvent::GetSeriesDetails(None); + let event = SonarrEvent::EditSeries(None); + + let (series_id, _) = if let Some(ref params) = edit_series_params { + self.extract_series_id(Some(params.series_id)).await + } else { + self.extract_series_id(None).await + }; + info!("Fetching series details for series with ID: {series_id}"); + + let request_props = self + .request_props_from( + detail_event, + RequestMethod::Get, + None::<()>, + Some(format!("/{series_id}")), + None, + ) + .await; + + let mut response = String::new(); + + self + .handle_request::<(), Value>(request_props, |detailed_series_body, _| { + response = detailed_series_body.to_string() + }) + .await?; + + info!("Constructing edit series body"); + + let mut detailed_series_body: Value = serde_json::from_str(&response).unwrap(); + let ( + monitored, + use_season_folders, + series_type, + quality_profile_id, + language_profile_id, + root_folder_path, + tags, + ) = if let Some(params) = edit_series_params { + let monitored = params.monitored.unwrap_or( + detailed_series_body["monitored"] + .as_bool() + .expect("Unable to deserialize 'monitored'"), + ); + let use_season_folders = params.use_season_folders.unwrap_or( + detailed_series_body["seasonFolder"] + .as_bool() + .expect("Unable to deserialize 'season_folder'"), + ); + let series_type = params + .series_type + .unwrap_or_else(|| { + serde_json::from_value(detailed_series_body["seriesType"].clone()) + .expect("Unable to deserialize 'seriesType'") + }) + .to_string(); + let quality_profile_id = params.quality_profile_id.unwrap_or_else(|| { + detailed_series_body["qualityProfileId"] + .as_i64() + .expect("Unable to deserialize 'qualityProfileId'") + }); + let language_profile_id = params.language_profile_id.unwrap_or_else(|| { + detailed_series_body["languageProfileId"] + .as_i64() + .expect("Unable to deserialize 'languageProfileId'") + }); + let root_folder_path = params.root_folder_path.unwrap_or_else(|| { + detailed_series_body["path"] + .as_str() + .expect("Unable to deserialize 'path'") + .to_owned() + }); + let tags = if params.clear_tags { + vec![] + } else { + params.tags.unwrap_or( + detailed_series_body["tags"] + .as_array() + .expect("Unable to deserialize 'tags'") + .iter() + .map(|item| item.as_i64().expect("Unable to deserialize tag ID")) + .collect(), + ) + }; + + ( + monitored, + use_season_folders, + series_type, + quality_profile_id, + language_profile_id, + root_folder_path, + tags, + ) + } else { + let tags = self + .app + .lock() + .await + .data + .sonarr_data + .edit_series_modal + .as_ref() + .unwrap() + .tags + .text + .clone(); + let tag_ids_vec = self.extract_and_add_sonarr_tag_ids_vec(tags).await; + let mut app = self.app.lock().await; + + let params = { + let EditSeriesModal { + monitored, + use_season_folders, + path, + series_type_list, + quality_profile_list, + language_profile_list, + .. + } = app.data.sonarr_data.edit_series_modal.as_ref().unwrap(); + let quality_profile = quality_profile_list.current_selection(); + let quality_profile_id = *app + .data + .sonarr_data + .quality_profile_map + .iter() + .filter(|(_, value)| *value == quality_profile) + .map(|(key, _)| key) + .next() + .unwrap(); + let language_profile = language_profile_list.current_selection(); + let language_profile_id = *app + .data + .sonarr_data + .language_profiles_map + .iter() + .filter(|(_, value)| *value == language_profile) + .map(|(key, _)| key) + .next() + .unwrap(); + + ( + monitored.unwrap_or_default(), + use_season_folders.unwrap_or_default(), + series_type_list.current_selection().to_string(), + quality_profile_id, + language_profile_id, + path.text.clone(), + tag_ids_vec, + ) + }; + + app.data.sonarr_data.edit_series_modal = None; + + params + }; + + *detailed_series_body.get_mut("monitored").unwrap() = json!(monitored); + *detailed_series_body.get_mut("seasonFolder").unwrap() = json!(use_season_folders); + *detailed_series_body.get_mut("seriesType").unwrap() = json!(series_type); + *detailed_series_body.get_mut("qualityProfileId").unwrap() = json!(quality_profile_id); + *detailed_series_body.get_mut("languageProfileId").unwrap() = json!(language_profile_id); + *detailed_series_body.get_mut("path").unwrap() = json!(root_folder_path); + *detailed_series_body.get_mut("tags").unwrap() = json!(tags); + + debug!("Edit series body: {detailed_series_body:?}"); + + let request_props = self + .request_props_from( + event, + RequestMethod::Put, + Some(detailed_series_body), + Some(format!("/{series_id}")), + None, + ) + .await; + + self + .handle_request::(request_props, |_, _| ()) + .await + } + async fn get_all_sonarr_indexer_settings(&mut self) -> Result { info!("Fetching Sonarr indexer settings"); let event = SonarrEvent::GetAllIndexerSettings; diff --git a/src/network/sonarr_network_tests.rs b/src/network/sonarr_network_tests.rs index 16ad396..3a3acd8 100644 --- a/src/network/sonarr_network_tests.rs +++ b/src/network/sonarr_network_tests.rs @@ -17,14 +17,14 @@ mod test { use crate::models::sonarr_models::{ AddSeriesBody, AddSeriesOptions, AddSeriesSearchResult, AddSeriesSearchResultStatistics, - IndexerSettings, SeriesMonitor, + EditSeriesParams, IndexerSettings, SeriesMonitor, }; use crate::app::{App, ServarrConfig}; use crate::models::radarr_models::IndexerTestResult; use crate::models::servarr_data::modals::{EditIndexerModal, IndexerTestResultModalItem}; use crate::models::servarr_data::sonarr::modals::{ - AddSeriesModal, EpisodeDetailsModal, SeasonDetailsModal, + AddSeriesModal, EditSeriesModal, EpisodeDetailsModal, SeasonDetailsModal, }; use crate::models::servarr_data::sonarr::sonarr_data::ActiveSonarrBlock; use crate::models::servarr_models::{ @@ -160,7 +160,8 @@ mod test { SonarrEvent::AddSeries(None), SonarrEvent::ListSeries, SonarrEvent::GetSeriesDetails(None), - SonarrEvent::DeleteSeries(None) + SonarrEvent::DeleteSeries(None), + SonarrEvent::EditSeries(None) )] event: SonarrEvent, ) { @@ -1690,6 +1691,215 @@ mod test { async_edit_server.assert_async().await; } + #[tokio::test] + async fn test_handle_edit_series_event() { + let mut expected_body: Value = serde_json::from_str(SERIES_JSON).unwrap(); + *expected_body.get_mut("monitored").unwrap() = json!(false); + *expected_body.get_mut("seasonFolder").unwrap() = json!(false); + *expected_body.get_mut("seriesType").unwrap() = json!("standard"); + *expected_body.get_mut("qualityProfileId").unwrap() = json!(1111); + *expected_body.get_mut("languageProfileId").unwrap() = json!(1111); + *expected_body.get_mut("path").unwrap() = json!("/nfs/Test Path"); + *expected_body.get_mut("tags").unwrap() = json!([1, 2]); + + let (async_details_server, app_arc, mut server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(serde_json::from_str(SERIES_JSON).unwrap()), + None, + SonarrEvent::GetSeriesDetails(None), + Some("/1"), + None, + ) + .await; + let async_edit_server = server + .mock( + "PUT", + format!("/api/v3{}/1", SonarrEvent::EditSeries(None).resource()).as_str(), + ) + .with_status(202) + .match_header("X-Api-Key", "test1234") + .match_body(Matcher::Json(expected_body)) + .create_async() + .await; + { + let mut app = app_arc.lock().await; + app.data.sonarr_data.tags_map = + BiMap::from_iter([(1, "usenet".to_owned()), (2, "testing".to_owned())]); + let mut edit_series = EditSeriesModal { + tags: "usenet, testing".to_owned().into(), + path: "/nfs/Test Path".to_owned().into(), + monitored: Some(false), + use_season_folders: Some(false), + ..EditSeriesModal::default() + }; + edit_series + .quality_profile_list + .set_items(vec!["Any".to_owned(), "HD - 1080p".to_owned()]); + edit_series + .language_profile_list + .set_items(vec!["Any".to_owned(), "English".to_owned()]); + edit_series + .series_type_list + .set_items(Vec::from_iter(SeriesType::iter())); + app.data.sonarr_data.edit_series_modal = Some(edit_series); + app.data.sonarr_data.series.set_items(vec![Series { + monitored: false, + season_folder: false, + ..series() + }]); + app.data.sonarr_data.quality_profile_map = + BiMap::from_iter([(1111, "Any".to_owned()), (2222, "HD - 1080p".to_owned())]); + app.data.sonarr_data.language_profiles_map = + BiMap::from_iter([(1111, "Any".to_owned()), (2222, "English".to_owned())]); + } + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + assert!(network + .handle_sonarr_event(SonarrEvent::EditSeries(None)) + .await + .is_ok()); + + async_details_server.assert_async().await; + async_edit_server.assert_async().await; + + let app = app_arc.lock().await; + assert!(app.data.sonarr_data.edit_series_modal.is_none()); + } + + #[tokio::test] + async fn test_handle_edit_series_event_uses_provided_parameters() { + let mut expected_body: Value = serde_json::from_str(SERIES_JSON).unwrap(); + *expected_body.get_mut("monitored").unwrap() = json!(false); + *expected_body.get_mut("seasonFolder").unwrap() = json!(false); + *expected_body.get_mut("seriesType").unwrap() = json!("standard"); + *expected_body.get_mut("qualityProfileId").unwrap() = json!(1111); + *expected_body.get_mut("languageProfileId").unwrap() = json!(1111); + *expected_body.get_mut("path").unwrap() = json!("/nfs/Test Path"); + *expected_body.get_mut("tags").unwrap() = json!([1, 2]); + + let (async_details_server, app_arc, mut server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(serde_json::from_str(SERIES_JSON).unwrap()), + None, + SonarrEvent::GetSeriesDetails(None), + Some("/1"), + None, + ) + .await; + let async_edit_server = server + .mock( + "PUT", + format!("/api/v3{}/1", SonarrEvent::EditSeries(None).resource()).as_str(), + ) + .with_status(202) + .match_header("X-Api-Key", "test1234") + .match_body(Matcher::Json(expected_body)) + .create_async() + .await; + let edit_series_params = EditSeriesParams { + series_id: 1, + monitored: Some(false), + use_season_folders: Some(false), + series_type: Some(SeriesType::Standard), + quality_profile_id: Some(1111), + language_profile_id: Some(1111), + root_folder_path: Some("/nfs/Test Path".to_owned()), + tags: Some(vec![1, 2]), + ..EditSeriesParams::default() + }; + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + assert!(network + .handle_sonarr_event(SonarrEvent::EditSeries(Some(edit_series_params))) + .await + .is_ok()); + + async_details_server.assert_async().await; + async_edit_server.assert_async().await; + } + + #[tokio::test] + async fn test_handle_edit_series_event_uses_provided_parameters_defaults_to_previous_values() { + let expected_body: Value = serde_json::from_str(SERIES_JSON).unwrap(); + let (async_details_server, app_arc, mut server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(serde_json::from_str(SERIES_JSON).unwrap()), + None, + SonarrEvent::GetSeriesDetails(None), + Some("/1"), + None, + ) + .await; + let async_edit_server = server + .mock( + "PUT", + format!("/api/v3{}/1", SonarrEvent::EditSeries(None).resource()).as_str(), + ) + .with_status(202) + .match_header("X-Api-Key", "test1234") + .match_body(Matcher::Json(expected_body)) + .create_async() + .await; + let edit_series_params = EditSeriesParams { + series_id: 1, + ..EditSeriesParams::default() + }; + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + assert!(network + .handle_sonarr_event(SonarrEvent::EditSeries(Some(edit_series_params))) + .await + .is_ok()); + + async_details_server.assert_async().await; + async_edit_server.assert_async().await; + } + + #[tokio::test] + async fn test_handle_edit_series_event_uses_provided_parameters_returns_empty_tags_vec_when_clear_tags_is_true( + ) { + let mut expected_body: Value = serde_json::from_str(SERIES_JSON).unwrap(); + *expected_body.get_mut("tags").unwrap() = json!([]); + + let (async_details_server, app_arc, mut server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(serde_json::from_str(SERIES_JSON).unwrap()), + None, + SonarrEvent::GetSeriesDetails(None), + Some("/1"), + None, + ) + .await; + let async_edit_server = server + .mock( + "PUT", + format!("/api/v3{}/1", SonarrEvent::EditSeries(None).resource()).as_str(), + ) + .with_status(202) + .match_header("X-Api-Key", "test1234") + .match_body(Matcher::Json(expected_body)) + .create_async() + .await; + let edit_series_params = EditSeriesParams { + series_id: 1, + clear_tags: true, + ..EditSeriesParams::default() + }; + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + assert!(network + .handle_sonarr_event(SonarrEvent::EditSeries(Some(edit_series_params))) + .await + .is_ok()); + + async_details_server.assert_async().await; + async_edit_server.assert_async().await; + } + #[rstest] #[tokio::test] async fn test_handle_get_sonarr_blocklist_event(#[values(true, false)] use_custom_sorting: bool) {