From 5ba3f2b1ba53e720a9f8b2405bcf2db625bf4886 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Sun, 24 Nov 2024 13:18:02 -0700 Subject: [PATCH] feat(network): Support for adding a new series to Sonarr --- src/models/servarr_data/sonarr/modals.rs | 63 +++- .../servarr_data/sonarr/modals_tests.rs | 58 +++ src/models/servarr_data/sonarr/sonarr_data.rs | 11 +- .../servarr_data/sonarr/sonarr_data_tests.rs | 3 + src/models/sonarr_models.rs | 35 +- src/network/radarr_network.rs | 8 +- src/network/radarr_network_tests.rs | 8 +- src/network/sonarr_network.rs | 152 +++++++- src/network/sonarr_network_tests.rs | 356 +++++++++++++++++- 9 files changed, 668 insertions(+), 26 deletions(-) create mode 100644 src/models/servarr_data/sonarr/modals_tests.rs diff --git a/src/models/servarr_data/sonarr/modals.rs b/src/models/servarr_data/sonarr/modals.rs index 4abf22c..c5a128d 100644 --- a/src/models/servarr_data/sonarr/modals.rs +++ b/src/models/servarr_data/sonarr/modals.rs @@ -1,9 +1,68 @@ +use strum::IntoEnumIterator; + use crate::models::{ - sonarr_models::{Episode, SonarrHistoryItem, SonarrRelease}, + servarr_models::RootFolder, + sonarr_models::{Episode, SeriesMonitor, SeriesType, SonarrHistoryItem, SonarrRelease}, + stateful_list::StatefulList, stateful_table::StatefulTable, - ScrollableText, + HorizontallyScrollableText, ScrollableText, }; +use super::sonarr_data::SonarrData; + +#[cfg(test)] +#[path = "modals_tests.rs"] +mod modals_tests; + +#[derive(Default)] +pub struct AddSeriesModal { + pub root_folder_list: StatefulList, + pub monitor_list: StatefulList, + pub quality_profile_list: StatefulList, + pub language_profile_list: StatefulList, + pub series_type_list: StatefulList, + pub use_season_folder: bool, + pub tags: HorizontallyScrollableText, +} + +impl From<&SonarrData> for AddSeriesModal { + fn from(sonarr_data: &SonarrData) -> AddSeriesModal { + let mut add_series_modal = AddSeriesModal { + use_season_folder: true, + ..AddSeriesModal::default() + }; + add_series_modal + .monitor_list + .set_items(Vec::from_iter(SeriesMonitor::iter())); + add_series_modal + .series_type_list + .set_items(Vec::from_iter(SeriesType::iter())); + let mut quality_profile_names: Vec = sonarr_data + .quality_profile_map + .right_values() + .cloned() + .collect(); + quality_profile_names.sort(); + add_series_modal + .quality_profile_list + .set_items(quality_profile_names); + let mut language_profile_names: Vec = sonarr_data + .language_profiles_map + .right_values() + .cloned() + .collect(); + language_profile_names.sort(); + add_series_modal + .language_profile_list + .set_items(language_profile_names); + add_series_modal + .root_folder_list + .set_items(sonarr_data.root_folders.items.to_vec()); + + add_series_modal + } +} + #[derive(Default)] pub struct EpisodeDetailsModal { pub episode_details: ScrollableText, diff --git a/src/models/servarr_data/sonarr/modals_tests.rs b/src/models/servarr_data/sonarr/modals_tests.rs new file mode 100644 index 0000000..fc25384 --- /dev/null +++ b/src/models/servarr_data/sonarr/modals_tests.rs @@ -0,0 +1,58 @@ +#[cfg(test)] +mod tests { + use bimap::BiMap; + use strum::IntoEnumIterator; + + use crate::models::{ + servarr_data::sonarr::{modals::AddSeriesModal, sonarr_data::SonarrData}, + servarr_models::RootFolder, + sonarr_models::{SeriesMonitor, SeriesType}, + }; + + #[test] + fn test_add_series_modal_from_sonarr_data() { + let root_folder = RootFolder { + id: 1, + path: "/nfs".to_owned(), + accessible: true, + free_space: 219902325555200, + unmapped_folders: None, + }; + let mut sonarr_data = SonarrData { + quality_profile_map: BiMap::from_iter([ + (2222, "HD - 1080p".to_owned()), + (1111, "Any".to_owned()), + ]), + language_profiles_map: BiMap::from_iter([ + (2222, "English".to_owned()), + (1111, "Any".to_owned()), + ]), + ..SonarrData::default() + }; + sonarr_data + .root_folders + .set_items(vec![root_folder.clone()]); + + let add_series_modal = AddSeriesModal::from(&sonarr_data); + + assert_eq!( + add_series_modal.monitor_list.items, + Vec::from_iter(SeriesMonitor::iter()) + ); + assert_eq!( + add_series_modal.series_type_list.items, + Vec::from_iter(SeriesType::iter()) + ); + assert_eq!( + add_series_modal.quality_profile_list.items, + vec!["Any".to_owned(), "HD - 1080p".to_owned()] + ); + assert_eq!( + add_series_modal.language_profile_list.items, + vec!["Any".to_owned(), "English".to_owned()] + ); + assert_eq!(add_series_modal.root_folder_list.items, vec![root_folder]); + assert!(add_series_modal.tags.text.is_empty()); + assert!(add_series_modal.use_season_folder); + } +} diff --git a/src/models/servarr_data/sonarr/sonarr_data.rs b/src/models/servarr_data/sonarr/sonarr_data.rs index 73278dd..4af41d6 100644 --- a/src/models/servarr_data/sonarr/sonarr_data.rs +++ b/src/models/servarr_data/sonarr/sonarr_data.rs @@ -6,14 +6,15 @@ use crate::models::{ servarr_data::modals::IndexerTestResultModalItem, servarr_models::{DiskSpace, Indexer, QueueEvent, RootFolder}, sonarr_models::{ - BlocklistItem, DownloadRecord, IndexerSettings, Season, Series, SonarrHistoryItem, SonarrTask, + AddSeriesSearchResult, BlocklistItem, DownloadRecord, IndexerSettings, Season, Series, + SonarrHistoryItem, SonarrTask, }, stateful_list::StatefulList, stateful_table::StatefulTable, HorizontallyScrollableText, Route, ScrollableText, }; -use super::modals::SeasonDetailsModal; +use super::modals::{AddSeriesModal, SeasonDetailsModal}; #[cfg(test)] #[path = "sonarr_data_tests.rs"] @@ -21,6 +22,9 @@ mod sonarr_data_tests; pub struct SonarrData { pub add_list_exclusion: bool, + pub add_searched_series: Option>, + pub add_series_modal: Option, + pub add_series_search: Option, pub blocklist: StatefulTable, pub delete_series_files: bool, pub downloads: StatefulTable, @@ -58,6 +62,9 @@ impl Default for SonarrData { fn default() -> SonarrData { SonarrData { add_list_exclusion: false, + add_searched_series: None, + add_series_search: None, + add_series_modal: None, blocklist: StatefulTable::default(), downloads: StatefulTable::default(), delete_series_files: false, diff --git a/src/models/servarr_data/sonarr/sonarr_data_tests.rs b/src/models/servarr_data/sonarr/sonarr_data_tests.rs index 11ac35a..a698b8f 100644 --- a/src/models/servarr_data/sonarr/sonarr_data_tests.rs +++ b/src/models/servarr_data/sonarr/sonarr_data_tests.rs @@ -49,6 +49,9 @@ mod tests { let sonarr_data = SonarrData::default(); assert!(!sonarr_data.add_list_exclusion); + assert!(sonarr_data.add_searched_series.is_none()); + assert!(sonarr_data.add_series_search.is_none()); + assert!(sonarr_data.add_series_modal.is_none()); assert!(sonarr_data.blocklist.is_empty()); assert!(!sonarr_data.delete_series_files); assert!(sonarr_data.downloads.is_empty()); diff --git a/src/models/sonarr_models.rs b/src/models/sonarr_models.rs index ae10239..46a4d04 100644 --- a/src/models/sonarr_models.rs +++ b/src/models/sonarr_models.rs @@ -27,19 +27,46 @@ mod sonarr_models_tests; pub struct AddSeriesBody { pub tvdb_id: i64, pub title: String, + pub monitored: bool, pub root_folder_path: String, pub quality_profile_id: i64, - pub series_type: SeriesType, - pub season_folder: bool, pub language_profile_id: i64, + pub series_type: String, + pub season_folder: bool, pub tags: Vec, pub add_options: AddSeriesOptions, } +#[derive(Derivative, Serialize, Deserialize, Debug, Default, Clone, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct AddSeriesSearchResult { + #[serde(deserialize_with = "super::from_i64")] + pub tvdb_id: i64, + pub title: HorizontallyScrollableText, + pub status: Option, + pub ended: bool, + pub overview: Option, + pub genres: Vec, + #[serde(deserialize_with = "super::from_i64")] + pub year: i64, + pub network: Option, + #[serde(deserialize_with = "super::from_i64")] + pub runtime: i64, + pub ratings: Option, + pub statistics: Option, +} + +#[derive(Derivative, Serialize, Deserialize, Debug, Default, Clone, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct AddSeriesSearchResultStatistics { + #[serde(deserialize_with = "super::from_i64")] + pub season_count: i64, +} + #[derive(Default, Clone, Serialize, Debug, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct AddSeriesOptions { - pub monitor: SeriesMonitor, + pub monitor: String, pub search_for_cutoff_unmet_episodes: bool, pub search_for_missing_episodes: bool, } @@ -258,9 +285,9 @@ pub struct Series { )] #[serde(rename_all = "camelCase")] pub enum SeriesMonitor { - Unknown, #[default] All, + Unknown, Future, Missing, Existing, diff --git a/src/network/radarr_network.rs b/src/network/radarr_network.rs index 82b2b1b..cbf8ae6 100644 --- a/src/network/radarr_network.rs +++ b/src/network/radarr_network.rs @@ -299,7 +299,7 @@ impl<'a, 'b> Network<'a, 'b> { .tags .text .clone(); - let tag_ids_vec = self.extract_and_add_tag_ids_vec(tags).await; + let tag_ids_vec = self.extract_and_add_radarr_tag_ids_vec(tags).await; let mut app = self.app.lock().await; let AddMovieModal { root_folder_list, @@ -1037,7 +1037,7 @@ impl<'a, 'b> Network<'a, 'b> { .tags .text .clone(); - let tag_ids_vec = self.extract_and_add_tag_ids_vec(tags).await; + let tag_ids_vec = self.extract_and_add_radarr_tag_ids_vec(tags).await; let mut app = self.app.lock().await; let params = { @@ -1222,7 +1222,7 @@ impl<'a, 'b> Network<'a, 'b> { .tags .text .clone(); - let tag_ids_vec = self.extract_and_add_tag_ids_vec(tags).await; + let tag_ids_vec = self.extract_and_add_radarr_tag_ids_vec(tags).await; let mut app = self.app.lock().await; let params = { @@ -2237,7 +2237,7 @@ impl<'a, 'b> Network<'a, 'b> { .await } - async fn extract_and_add_tag_ids_vec(&mut self, edit_tags: String) -> Vec { + async fn extract_and_add_radarr_tag_ids_vec(&mut self, edit_tags: String) -> Vec { let tags_map = self.app.lock().await.data.radarr_data.tags_map.clone(); let tags = edit_tags.clone(); let missing_tags_vec = edit_tags diff --git a/src/network/radarr_network_tests.rs b/src/network/radarr_network_tests.rs index 7d8b442..be349a0 100644 --- a/src/network/radarr_network_tests.rs +++ b/src/network/radarr_network_tests.rs @@ -4876,7 +4876,7 @@ mod test { } #[tokio::test] - async fn test_extract_and_add_tag_ids_vec() { + async fn test_extract_and_add_radarr_tag_ids_vec() { let app_arc = Arc::new(Mutex::new(App::default())); let tags = " test,hi ,, usenet ".to_owned(); { @@ -4890,13 +4890,13 @@ mod test { let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); assert_eq!( - network.extract_and_add_tag_ids_vec(tags).await, + network.extract_and_add_radarr_tag_ids_vec(tags).await, vec![2, 3, 1] ); } #[tokio::test] - async fn test_extract_and_add_tag_ids_vec_add_missing_tags_first() { + async fn test_extract_and_add_radarr_tag_ids_vec_add_missing_tags_first() { let (async_server, app_arc, _server) = mock_servarr_api( RequestMethod::Post, Some(json!({ "label": "testing" })), @@ -4919,7 +4919,7 @@ mod test { } let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); - let tag_ids_vec = network.extract_and_add_tag_ids_vec(tags).await; + let tag_ids_vec = network.extract_and_add_radarr_tag_ids_vec(tags).await; async_server.assert_async().await; assert_eq!(tag_ids_vec, vec![1, 2, 3]); diff --git a/src/network/sonarr_network.rs b/src/network/sonarr_network.rs index fac78d7..14001ad 100644 --- a/src/network/sonarr_network.rs +++ b/src/network/sonarr_network.rs @@ -9,7 +9,7 @@ use crate::{ servarr_data::{ modals::IndexerTestResultModalItem, sonarr::{ - modals::{EpisodeDetailsModal, SeasonDetailsModal}, + modals::{AddSeriesModal, EpisodeDetailsModal, SeasonDetailsModal}, sonarr_data::ActiveSonarrBlock, }, }, @@ -18,10 +18,10 @@ use crate::{ QualityProfile, QueueEvent, RootFolder, SecurityConfig, Tag, Update, }, sonarr_models::{ - BlocklistResponse, DeleteSeriesParams, DownloadRecord, DownloadsResponse, Episode, - IndexerSettings, Series, SonarrCommandBody, SonarrHistoryItem, SonarrHistoryWrapper, - SonarrRelease, SonarrReleaseDownloadBody, SonarrSerdeable, SonarrTask, SonarrTaskName, - SystemStatus, + AddSeriesBody, AddSeriesOptions, AddSeriesSearchResult, BlocklistResponse, + DeleteSeriesParams, DownloadRecord, DownloadsResponse, Episode, IndexerSettings, Series, + SonarrCommandBody, SonarrHistoryItem, SonarrHistoryWrapper, SonarrRelease, + SonarrReleaseDownloadBody, SonarrSerdeable, SonarrTask, SonarrTaskName, SystemStatus, }, stateful_table::StatefulTable, HorizontallyScrollableText, Route, Scrollable, ScrollableText, @@ -38,6 +38,7 @@ mod sonarr_network_tests; #[derive(Debug, Eq, PartialEq, Clone)] pub enum SonarrEvent { AddRootFolder(Option), + AddSeries(Option), AddTag(String), ClearBlocklist, DeleteBlocklistItem(Option), @@ -120,9 +121,10 @@ impl NetworkResource for SonarrEvent { SonarrEvent::GetTasks => "/system/task", SonarrEvent::GetUpdates => "/update", SonarrEvent::HealthCheck => "/health", - SonarrEvent::ListSeries | SonarrEvent::GetSeriesDetails(_) | SonarrEvent::DeleteSeries(_) => { - "/series" - } + SonarrEvent::AddSeries(_) + | SonarrEvent::ListSeries + | SonarrEvent::GetSeriesDetails(_) + | SonarrEvent::DeleteSeries(_) => "/series", SonarrEvent::MarkHistoryItemAsFailed(_) => "/history/failed", SonarrEvent::TestIndexer(_) => "/indexer/test", SonarrEvent::TestAllIndexers => "/indexer/testall", @@ -146,6 +148,10 @@ impl<'a, 'b> Network<'a, 'b> { .add_sonarr_root_folder(path) .await .map(SonarrSerdeable::from), + SonarrEvent::AddSeries(body) => self + .add_sonarr_series(body) + .await + .map(SonarrSerdeable::from), SonarrEvent::AddTag(tag) => self.add_sonarr_tag(tag).await.map(SonarrSerdeable::from), SonarrEvent::ClearBlocklist => self .clear_sonarr_blocklist() @@ -327,6 +333,106 @@ impl<'a, 'b> Network<'a, 'b> { .await } + async fn add_sonarr_series( + &mut self, + add_series_body_option: Option, + ) -> Result { + info!("Adding new series to Sonarr"); + let event = SonarrEvent::AddSeries(None); + let body = if let Some(add_series_body) = add_series_body_option { + add_series_body + } else { + let tags = self + .app + .lock() + .await + .data + .sonarr_data + .add_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 AddSeriesModal { + root_folder_list, + monitor_list, + quality_profile_list, + language_profile_list, + series_type_list, + use_season_folder, + .. + } = app.data.sonarr_data.add_series_modal.as_ref().unwrap(); + let season_folder = *use_season_folder; + let (tvdb_id, title) = { + let AddSeriesSearchResult { tvdb_id, title, .. } = app + .data + .sonarr_data + .add_searched_series + .as_ref() + .unwrap() + .current_selection() + .clone(); + (tvdb_id, title.text) + }; + 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(); + + let path = root_folder_list.current_selection().path.clone(); + let monitor = monitor_list.current_selection().to_string(); + let series_type = series_type_list.current_selection().to_string(); + + app.data.sonarr_data.add_series_modal = None; + + AddSeriesBody { + tvdb_id, + title, + monitored: true, + root_folder_path: path, + quality_profile_id, + language_profile_id, + series_type, + season_folder, + tags: tag_ids_vec, + add_options: AddSeriesOptions { + monitor, + search_for_cutoff_unmet_episodes: true, + search_for_missing_episodes: true, + }, + } + }; + + debug!("Add series body: {body:?}"); + + let request_props = self + .request_props_from(event, RequestMethod::Post, Some(body), None, None) + .await; + + self + .handle_request::(request_props, |_, _| ()) + .await + } + async fn add_sonarr_tag(&mut self, tag: String) -> Result { info!("Adding a new Sonarr tag"); let event = SonarrEvent::AddTag(String::new()); @@ -1703,6 +1809,36 @@ impl<'a, 'b> Network<'a, 'b> { .await } + async fn extract_and_add_sonarr_tag_ids_vec(&mut self, edit_tags: String) -> Vec { + let tags_map = self.app.lock().await.data.sonarr_data.tags_map.clone(); + let tags = edit_tags.clone(); + let missing_tags_vec = edit_tags + .split(',') + .filter(|&tag| !tag.is_empty() && tags_map.get_by_right(tag.trim()).is_none()) + .collect::>(); + + for tag in missing_tags_vec { + self + .add_sonarr_tag(tag.trim().to_owned()) + .await + .expect("Unable to add tag"); + } + + let app = self.app.lock().await; + tags + .split(',') + .filter(|tag| !tag.is_empty()) + .map(|tag| { + *app + .data + .sonarr_data + .tags_map + .get_by_right(tag.trim()) + .unwrap() + }) + .collect() + } + async fn extract_series_id(&mut self, series_id: Option) -> (i64, String) { let series_id = if let Some(id) = series_id { id diff --git a/src/network/sonarr_network_tests.rs b/src/network/sonarr_network_tests.rs index f7a0b63..82349ee 100644 --- a/src/network/sonarr_network_tests.rs +++ b/src/network/sonarr_network_tests.rs @@ -11,13 +11,21 @@ mod test { use rstest::rstest; use serde_json::json; use serde_json::{Number, Value}; + use strum::IntoEnumIterator; use tokio::sync::Mutex; use tokio_util::sync::CancellationToken; + use crate::models::sonarr_models::{ + AddSeriesBody, AddSeriesOptions, AddSeriesSearchResult, AddSeriesSearchResultStatistics, + SeriesMonitor, + }; + use crate::app::App; use crate::models::radarr_models::IndexerTestResult; use crate::models::servarr_data::modals::IndexerTestResultModalItem; - use crate::models::servarr_data::sonarr::modals::{EpisodeDetailsModal, SeasonDetailsModal}; + use crate::models::servarr_data::sonarr::modals::{ + AddSeriesModal, EpisodeDetailsModal, SeasonDetailsModal, + }; use crate::models::servarr_data::sonarr::sonarr_data::ActiveSonarrBlock; use crate::models::servarr_models::{ DiskSpace, HostConfig, Indexer, IndexerField, Language, LogResponse, Quality, QualityProfile, @@ -33,7 +41,7 @@ mod test { use crate::models::sonarr_models::{SonarrTask, SystemStatus}; use crate::models::stateful_table::StatefulTable; use crate::models::{sonarr_models::SonarrSerdeable, stateful_table::SortOption}; - use crate::models::{HorizontallyScrollableText, ScrollableText}; + use crate::models::{HorizontallyScrollableText, Scrollable, ScrollableText}; use crate::network::sonarr_network::get_episode_status; use crate::{ @@ -138,6 +146,7 @@ mod test { #[rstest] fn test_resource_series( #[values( + SonarrEvent::AddSeries(None), SonarrEvent::ListSeries, SonarrEvent::GetSeriesDetails(None), SonarrEvent::DeleteSeries(None) @@ -321,6 +330,267 @@ mod test { .is_none()); } + #[tokio::test] + async fn test_handle_add_sonarr_series_event() { + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Post, + Some(json!({ + "tvdbId": 1234, + "title": "Test", + "monitored": true, + "rootFolderPath": "/nfs2", + "qualityProfileId": 2222, + "languageProfileId": 2222, + "seriesType": "standard", + "seasonFolder": true, + "tags": [1, 2], + "addOptions": { + "monitor": "all", + "searchForCutoffUnmetEpisodes": true, + "searchForMissingEpisodes": true + } + })), + Some(json!({})), + None, + SonarrEvent::AddSeries(None), + None, + None, + ) + .await; + + { + let mut app = app_arc.lock().await; + let mut add_series_modal = AddSeriesModal { + use_season_folder: true, + tags: "usenet, testing".into(), + ..AddSeriesModal::default() + }; + add_series_modal.root_folder_list.set_items(vec![ + RootFolder { + id: 1, + path: "/nfs".to_owned(), + accessible: true, + free_space: 219902325555200, + unmapped_folders: None, + }, + RootFolder { + id: 2, + path: "/nfs2".to_owned(), + accessible: true, + free_space: 21990232555520, + unmapped_folders: None, + }, + ]); + add_series_modal.root_folder_list.state.select(Some(1)); + add_series_modal + .quality_profile_list + .set_items(vec!["HD - 1080p".to_owned()]); + add_series_modal + .language_profile_list + .set_items(vec!["English".to_owned()]); + add_series_modal + .monitor_list + .set_items(Vec::from_iter(SeriesMonitor::iter())); + add_series_modal + .series_type_list + .set_items(Vec::from_iter(SeriesType::iter())); + app.data.sonarr_data.add_series_modal = Some(add_series_modal); + app.data.sonarr_data.quality_profile_map = + BiMap::from_iter([(2222, "HD - 1080p".to_owned())]); + app.data.sonarr_data.language_profiles_map = BiMap::from_iter([(2222, "English".to_owned())]); + app.data.sonarr_data.tags_map = + BiMap::from_iter([(1, "usenet".to_owned()), (2, "testing".to_owned())]); + let mut add_searched_series = StatefulTable::default(); + add_searched_series.set_items(vec![add_series_search_result()]); + app.data.sonarr_data.add_searched_series = Some(add_searched_series); + } + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + assert!(network + .handle_sonarr_event(SonarrEvent::AddSeries(None)) + .await + .is_ok()); + + async_server.assert_async().await; + assert!(app_arc + .lock() + .await + .data + .sonarr_data + .add_series_modal + .is_none()); + } + + #[tokio::test] + async fn test_handle_add_sonarr_series_event_uses_provided_body() { + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Post, + Some(json!({ + "tvdbId": 1234, + "title": "Test", + "monitored": true, + "rootFolderPath": "/nfs2", + "qualityProfileId": 2222, + "languageProfileId": 2222, + "seriesType": "standard", + "seasonFolder": true, + "tags": [1, 2], + "addOptions": { + "monitor": "standard", + "searchForCutoffUnmetEpisodes": true, + "searchForMissingEpisodes": true + } + })), + Some(json!({})), + None, + SonarrEvent::AddSeries(None), + None, + None, + ) + .await; + let body = AddSeriesBody { + tvdb_id: 1234, + title: "Test".to_owned(), + monitored: true, + root_folder_path: "/nfs2".to_owned(), + quality_profile_id: 2222, + language_profile_id: 2222, + series_type: "standard".to_owned(), + season_folder: true, + tags: vec![1, 2], + add_options: AddSeriesOptions { + monitor: "standard".to_owned(), + search_for_cutoff_unmet_episodes: true, + search_for_missing_episodes: true, + }, + }; + + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + assert!(network + .handle_sonarr_event(SonarrEvent::AddSeries(Some(body))) + .await + .is_ok()); + + async_server.assert_async().await; + assert!(app_arc + .lock() + .await + .data + .sonarr_data + .add_series_modal + .is_none()); + } + + #[tokio::test] + async fn test_handle_add_sonarr_series_event_reuse_existing_table_if_search_already_performed() { + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Post, + Some(json!({ + "tvdbId": 5678, + "title": "Test", + "monitored": true, + "rootFolderPath": "/nfs2", + "qualityProfileId": 2222, + "languageProfileId": 2222, + "seriesType": "standard", + "seasonFolder": true, + "tags": [1, 2], + "addOptions": { + "monitor": "all", + "searchForCutoffUnmetEpisodes": true, + "searchForMissingEpisodes": true + } + })), + Some(json!({})), + None, + SonarrEvent::AddSeries(None), + None, + None, + ) + .await; + + { + let mut app = app_arc.lock().await; + let mut add_series_modal = AddSeriesModal { + use_season_folder: true, + tags: "usenet, testing".into(), + ..AddSeriesModal::default() + }; + add_series_modal.root_folder_list.set_items(vec![ + RootFolder { + id: 1, + path: "/nfs".to_owned(), + accessible: true, + free_space: 219902325555200, + unmapped_folders: None, + }, + RootFolder { + id: 2, + path: "/nfs2".to_owned(), + accessible: true, + free_space: 21990232555520, + unmapped_folders: None, + }, + ]); + add_series_modal.root_folder_list.state.select(Some(1)); + add_series_modal + .quality_profile_list + .set_items(vec!["HD - 1080p".to_owned()]); + add_series_modal + .language_profile_list + .set_items(vec!["English".to_owned()]); + add_series_modal + .monitor_list + .set_items(Vec::from_iter(SeriesMonitor::iter())); + add_series_modal + .series_type_list + .set_items(Vec::from_iter(SeriesType::iter())); + app.data.sonarr_data.add_series_modal = Some(add_series_modal); + app.data.sonarr_data.quality_profile_map = + BiMap::from_iter([(2222, "HD - 1080p".to_owned())]); + app.data.sonarr_data.language_profiles_map = BiMap::from_iter([(2222, "English".to_owned())]); + app.data.sonarr_data.tags_map = + BiMap::from_iter([(1, "usenet".to_owned()), (2, "testing".to_owned())]); + let secondary_search_result = AddSeriesSearchResult { + tvdb_id: 5678, + ..add_series_search_result() + }; + let mut add_searched_series = StatefulTable::default(); + add_searched_series.set_items(vec![add_series_search_result(), secondary_search_result]); + add_searched_series.scroll_to_bottom(); + app.data.sonarr_data.add_searched_series = Some(add_searched_series); + } + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + assert!(network + .handle_sonarr_event(SonarrEvent::AddSeries(None)) + .await + .is_ok()); + + async_server.assert_async().await; + assert!(app_arc + .lock() + .await + .data + .sonarr_data + .add_series_modal + .is_none()); + assert_eq!( + app_arc + .lock() + .await + .data + .sonarr_data + .add_searched_series + .as_ref() + .unwrap() + .current_selection() + .tvdb_id, + 5678 + ); + } + #[tokio::test] async fn test_handle_add_sonarr_tag() { let tag_json = json!({ "id": 3, "label": "testing" }); @@ -4851,6 +5121,64 @@ mod test { async_server.assert_async().await; } + #[tokio::test] + async fn test_extract_and_add_sonarr_tag_ids_vec() { + let app_arc = Arc::new(Mutex::new(App::default())); + let tags = " test,hi ,, usenet ".to_owned(); + { + let mut app = app_arc.lock().await; + app.data.sonarr_data.tags_map = BiMap::from_iter([ + (1, "usenet".to_owned()), + (2, "test".to_owned()), + (3, "hi".to_owned()), + ]); + } + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + assert_eq!( + network.extract_and_add_sonarr_tag_ids_vec(tags).await, + vec![2, 3, 1] + ); + } + + #[tokio::test] + async fn test_extract_and_add_sonarr_tag_ids_vec_add_missing_tags_first() { + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Post, + Some(json!({ "label": "testing" })), + Some(json!({ "id": 3, "label": "testing" })), + None, + SonarrEvent::GetTags, + None, + None, + ) + .await; + let tags = "usenet, test, testing".to_owned(); + { + let mut app = app_arc.lock().await; + app.data.sonarr_data.add_series_modal = Some(AddSeriesModal { + tags: tags.clone().into(), + ..AddSeriesModal::default() + }); + app.data.sonarr_data.tags_map = + BiMap::from_iter([(1, "usenet".to_owned()), (2, "test".to_owned())]); + } + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + let tag_ids_vec = network.extract_and_add_sonarr_tag_ids_vec(tags).await; + + async_server.assert_async().await; + assert_eq!(tag_ids_vec, vec![1, 2, 3]); + assert_eq!( + app_arc.lock().await.data.sonarr_data.tags_map, + BiMap::from_iter([ + (1, "usenet".to_owned()), + (2, "test".to_owned()), + (3, "testing".to_owned()) + ]) + ); + } + #[tokio::test] async fn test_extract_series_id() { let app_arc = Arc::new(Mutex::new(App::default())); @@ -5084,6 +5412,26 @@ mod test { ); } + fn add_series_search_result() -> AddSeriesSearchResult { + AddSeriesSearchResult { + tvdb_id: 1234, + title: HorizontallyScrollableText::from("Test"), + status: Some("continuing".to_owned()), + ended: false, + overview: Some("New series blah blah blah".to_owned()), + genres: genres(), + year: 2023, + network: Some("Prime Video".to_owned()), + runtime: 60, + ratings: Some(rating()), + statistics: Some(add_series_search_result_statistics()), + } + } + + fn add_series_search_result_statistics() -> AddSeriesSearchResultStatistics { + AddSeriesSearchResultStatistics { season_count: 3 } + } + fn blocklist_item() -> BlocklistItem { BlocklistItem { id: 1, @@ -5151,6 +5499,10 @@ mod test { } } + fn genres() -> Vec { + vec!["cool".to_owned(), "family".to_owned(), "fun".to_owned()] + } + fn history_data() -> SonarrHistoryData { SonarrHistoryData { dropped_path: Some("/nfs/nzbget/completed/series/Coolness/something.cool.mkv".to_owned()),